Repository navigation
Differentiation and canonical ordering visit each shared subexpression once - #506
Merged
ofloveandhate merged 4 commits intoOct 11, 2026
Conversation
…r call (bertiniteam#505) Differentiate called Differentiate on each operand with no memo, so a subexpression reached by many paths was differentiated once per path: the sub-minors of a determinant built by cofactor expansion k! times at the top. A System's first evaluation builds its Jacobian, so it paid that: 24.6 s for a 7 x 7 determinant of quadratics. Node::Differentiate is now non-virtual and keeps each derivative for the length of the outermost call; a node type's rule is DifferentiateImpl. A DerivativeScope shares the memo across a loop, and both Jacobian builds open one, so functions that share subexpressions differentiate each once among them. Degree(v) had the same hole. MultiDegree is memoized (bertiniteam#417), but a unary operator's multidegree is computed from Degree(v) of its operand, which walked the whole expansion, and canonicalizing every sum and product asks its operands' multidegrees. Degree(v) is now memoized the same way, with DegreeImpl as the per-type rule. The canonical order, and so every digest, is unchanged. Python subclasses of Node still override Differentiate and Degree by name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er walks its operands (bertiniteam#505) Canonicalizing a sum or product asks each operand's variables, multidegree and, on a tie, canonical encoding. Each was a walk of the operand's DAG, so building an expression cost the square of its size; the derivative of a 7 x 7 determinant took 7 s after the differentiation memo, nearly all of it here. Each node now makes, once and from its operands' records, a record of its variables, its degree and multidegree in each variable it mentions (its variables and its differentials'), its total degree, and one answer for every variable it does not mention. That last is not always 0 -- a negative constant power reports -1 in every variable -- so it is computed by asking the rules about a variable no expression contains, which answers for all of them because the rules compare a variable only by identity. Degree, MultiDegree and GatherVariables read the record, which replaces the per-call memos. The record is published once with an atomic compare-and-swap, since systems compile lazily on worker threads. A node also keeps its canonical encoding once computed. Barth sextic, 50 deflated critical-point systems: first evaluations 121 s to 0.6 s. The canonical order and every digest are unchanged; the golden digest fixtures pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion visits each node once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… canonical encoding; b2sysenc/3 (bertiniteam#505) Operands tied on multidegree were ordered by their whole canonical encoding. Kept per node it is computed once, but each computation walks the node's DAG -- its back-references number the whole tree, so it cannot be made from the operands' encodings -- and building an expression still cost O(DAG^2): 1.4 s for the first evaluation of a 7 x 7 determinant, nearly all of it encoding. A node's content key is now the SHA-256 of its shallow canonical encoding: its own kind, payload and operands in the canonical format, each operand written as @ and that operand's key. It is made once per node from the operands' keys. The 7 x 7 determinant takes 0.04 s (24.6 s in 4.0.0). Tied operands reorder, so the system encoding version is b2sysenc/3, registered for 4.0.1, and the golden fixture and the pinned seed-42 homotopy digest are regenerated. The fixture gains a recipe with tied operands (tied_terms): no recipe had a tie, so the registry could not see a tie-break change; under this one the old and new tie-breaks order its products differently. ADR-0073 records the key; ADR-0059's tie-break key is marked replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
End to end, on the cellular decomposition of the Barth sextic (seed 1042, 11 CPUs): 418.6 s on 4.0.0 and 319.3 s with this branch, with the same result (3926 faces, Euler characteristic 10). Stage 2, which sharpens the critical points and deflates the 50 singular ones, went from 86.5 s to 69.1 s; the slice curves and boundary curves, which find and refine critical points on every slice, from 178.5 s to 149.5 s and 51.8 s to 37.9 s. |
Contributor
Author
|
this was super fun to do. it was a glorious weekend of making my math dreams come true. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #505.
A
System's first evaluation builds its Jacobian, and for systems whose functions share subexpressions heavily -- a determinant built by cofactor expansion, the minors deflation appends -- that cost time proportional to the expanded tree rather than the DAG: 24.6 s for a 7 x 7 determinant of quadratics in three variables. Two things were proportional to the expansion, and a third to the square of the DAG.Differentiation had no memo.
SumOperator::DifferentiateandMultOperator::Differentiatedifferentiated each operand, so a subexpression reached by many paths was differentiated once per path.Node::Differentiateis now non-virtual and keeps each derivative for the length of the outermost call; a node type's rule isDifferentiateImpl. Anode::DerivativeScopeextends the memo across a loop, and both Jacobian builds open one, so functions that share subexpressions differentiate each once among them.Degree(v)walked the expansion.MultiDegreehas been memoized per call since #418, but a unary operator's multidegree is computed from its operand'sDegree(v), which was not, so every construction above a negation walked the whole expansion beneath it.Building a sum or product walked its operands. Canonical ordering asks each operand its variables, its multidegree and, on a tie, its canonical encoding, and each answer was a walk of the operand's DAG -- O(DAG) per construction, O(DAG^2) to build an expression. Each node now keeps a degree record, made once from its operands' records: its variables, its degree and multidegree in each variable it mentions, its total degree, and one answer for every variable it does not mention.
Degree,MultiDegreeandGatherVariablesread it.The tie-break is a content key. The full canonical encoding cannot be made from the operands' encodings -- its back-references number the whole tree -- so even computed once per node it walked each node's DAG. Operands that tie on multidegree are now ordered by a content key: the SHA-256 of the node's shallow canonical encoding, its own kind, payload and operands in the canonical format with each operand written as
@and that operand's key. A Merkle hash, made once per node from the operands' keys.The answer for an unmentioned variable is not always 0 -- a power with a negative constant exponent reports -1 in every variable -- so the record computes it, by asking the degree rules about a variable that is in no expression (default-constructed, never interned). The rules compare a variable only by identity with the variables a subtree mentions, so that one answer is every unmentioned variable's. Records and keys are published once by atomic compare-and-swap, since systems compile lazily on worker threads.
Measured
First evaluation of a k x k determinant of dense quadratics in three variables, sub-minors shared:
The growth per step is now about that of the DAG.
In a cellular decomposition of the Barth sextic (bertini_real's algorithm on b2), each of 50 singular critical points gets a deflated system of 7 functions in 6 variables, evaluated once to check a refined point. Those first evaluations took 121 s in all (2.5 s at most) and take 0.6 s (21 ms at most).
Identity
Where operands tie on multidegree their canonical order changes, so the system encoding version is
b2sysenc/3, registered for 4.0.1, and every system digest moves: records written by 4.0.0 are asked again rather than recalled. The golden fixture is regenerated, as is the pinned seed-42 homotopy digest. The fixture gainstied_terms, a recipe whose operands tie: no recipe had a tie, so the registry could not see a change to the tie-break. The old and new tie-breaks order its products differently. The degree record changes no order.Interface
DifferentiateandDegree(v)are non-virtual; node types overrideDifferentiateImplandDegreeImpl. A Python subclass ofNodestill overridesDifferentiateandDegreeby name.Node::Variables()andNode::ContentKey()are new.Tests
DerivativeScopegives the derivatives of separate calls; an unmentioned variable gets the rules' answer, -1 for a negative constant power and 0 for a polynomial.BERTINI_NUM_THREADSunset and=1; the Python suite passes.ADR-0073 records the decision and what not to undo; ADR-0059's tie-break key is marked replaced by it. CHANGELOG entries are under 4.0.1.
🤖 Generated with Claude Code