Repository navigation
Override Eigen's product evaluators - #3456
Open
SteveBronder wants to merge 8 commits into
Open
SteveBronder wants to merge 8 commits into
SteveBronder wants to merge 8 commits into
Conversation
Eigen `operator*` on matrices and arrays with `var`, `fvar<T>`, `std::complex<var>` or `std::complex<fvar<T>>` scalars now dispatches to Stan kernels instead of Eigen's per-scalar kernels: - Matrix products (every product tag, lazyProduct, noalias, =, +=, -=) through a prim-level specialization of Eigen's dense product_evaluator that forwards autodiff products to eigen_matrix_product_impl and keeps Eigen's own evaluation for all other scalars, plus Assignment specializations for same-scalar destinations. - Coefficient-wise products and products with a scalar through binary_evaluator specializations for scalar_product_op. - Scalar factors are folded into the product they scale, so s * (A * B), (A * B) * s and (s * A) * B each record one callback. The reverse-mode kernel records one callback plus one nochain vari per result coefficient. The forward-mode kernel splits fvar products into products of values and tangents (one concatenated product for the tangent), so fvar<var> matrix products record two callbacks instead of several varis per scalar operation; tape-free fvar<double> element-wise products stay lazy.
Scalar factors of a product kernel are stored on the arena at the first autodiff factor instead of in a fixed array inside every callback, result adjoints are read lazily, and var operand adjoints are updated with one vectorized expression.
Replace the evaluator/customization-point layers and the rev and fwd product kernels with specializations of Eigen's structs that call Stan's free functions directly: - product_evaluator (dense and per-tag lazy) calls multiply, or scale_multiply when an operand is Eigen's `s * A` / `A * s`. - binary_evaluator for scalar_product_op of var with var, double or int calls elt_multiply, multiply(s, X) or scale_multiply. - Assignment of a product to a var destination calls multiply, multiply_add (`noalias() +=`, `C + A * B`) or subtract (`noalias() -=`). Add scale_multiply(s, A, B) and multiply_add(A, B, C) (prim and rev), each recording one callback. This pass handles real var only: products with fvar or complex scalars use Eigen's evaluation, and the tests that need them are reverse-only or removed until those are routed as well.
Move the hooks header to stan/math/rev/meta and replace the macros with plain specializations of Eigen's structs: - binary_evaluator: one specialization. Its var condition sits in the CwiseBinaryOp functor argument, which Eigen's specialization leaves free, so it covers var with var, double and int in one place. - Assignment: one specialization per functor (=, +=, -=). The condition sits in both scalars of the functor, so products of non-dense operands run Eigen's own specialization and the copied Eigen bodies are gone. Inner products are now computed by multiply as well. - product_evaluator and binary_evaluator share one evaluator body, ad_product_evaluator, which no longer allocates a result it discards. - Lazy products (lazyProduct()) are left to Eigen. The traits strip references and const themselves, with require_eigen_product_t and require_eigen_cwise_product_t aliases. Every Stan specialization derives from eigen_ad_hook so tests can check which struct Eigen picks. scale_multiply and multiply_add return their arena result instead of copying it to the heap. Tests: eigen_hook_dispatch_test checks which specialization Eigen picks for each scalar pairing, operator and destination; MRE tests instantiate each struct on its own; eigen_product_hook_test gains products nested in larger expressions, Stan functions and block/transpose destinations.
Fixed-size var products now go through multiply, whose callback captures fixed-size Eigen matrices inside an 8 byte aligned vari. They are fixed on fix/aligned-vari-alloc; add these tests back once that is merged.
- Add multiply_sub(A, B, C) = C - A * B (prim and rev), one reverse pass callback like multiply_add. `C.noalias() -= A * B` now uses it instead of subtract(C, multiply(A, B)), so it records one callback instead of two. - scale_multiply and multiply_add take their arguments by forwarding reference. The rev versions read operand values through value_of instead of caching copies, evaluating the result adjoint once per callback. The prim versions return a lazy expression through make_holder. - Fix rev scale_multiply computing its value with the var scale, which recorded a vari per coefficient on top of the callback. - Remove test/unit/math/ad_tape_util.hpp and read the reverse-mode stack sizes directly in the tests that used it; glm_forward_mode_tape_test is back to develop's version.
Contributor
Collaborator
Author
|
Yes if this PR works we could revert that PR, but I think there is still a lot of work to get this in shape |
Stan's functions assume an Eigen::Map points at arena memory and keep the Map in their reverse-pass callbacks (arena_matrix(const Map&) does not copy), so a callback reads whatever vars the mapped memory holds during the reverse pass. Eigen's own algorithms map workspaces they reuse and free: HouseholderQR's reflections (Householder.h applyHouseholderOnTheLeft) map a var workspace that every reflector overwrites and that is freed when the QR object or HouseholderSequence::evalTo returns. Callbacks then sent adjoints to the wrong varis or read freed memory, which segfaulted MathMixMatFun.qrQ on arm64 (and in optimized x86 builds). copy_var_map copies the vars (not their varis) of a contiguous Map of vars into the arena and passes everything else through. The hooks apply it to every operand they pass to multiply, scale_multiply, elt_multiply, multiply_add and multiply_sub, including the destination of += / -=. Double Maps and arena_matrix are not copied. Tests: Map operands and Map destinations whose buffer is refilled with new vars before the reverse pass must still propagate the gradient to the vars the forward pass used. Also adds is_eigen_array_v and has is_eigen_array decay its argument, and moves the dispatch tests onto the AgradRev fixture.
This branch has not been deployed
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.
Summary
This makes Eigen's
operator*onvarmatrices call ourrev/fwdfunctions instead of Eigen'sProductevaluators. SoA * Brecords one callback throughmultiplyinstead of O(n^3)varis. This works for matrix and array like types.In order to be able to overload Eigen's
Assignmentand binary evaluator types this PR also adds a few other compound matrix multiplication operators thatscale_multiply(scalar, Mat1, Mat2)fors * (A * B)scale_multiply(s, A, B)fors * (A * B)multiply_add(A, B, C)forC.noalias() += A * Bmultiply_sub(A, B, C)forC.noalias() -= A * BGetting this to work required specializing Eigen's
product_evaluator,binary_evaluator, andAssignmentstructs so that if they saw an output or input with a scalarvartype the compiler would select our specializations instead of Eigens. This is kind of a brutal task because we essentially need to find the set of template parameters we can fix in our struct specialization such that the compiler will choose ours instead of Eigens.To get this to work I had to build a bunch of test cases of basic matrix multiplication and then have a check that, if our overload was used, the resulting expression would have an extra tag that shows our specialization was used. Then I threw the problem at a bunch of claudes because I've tried fiddling with this before and it was horrible. But claude can try many insane combinations of specializations for the structs till they find a set that works correctly for our code. After it found a set that worked, I did a bunch of refinement to figure out what the smallest set of specializations was needed such that we need as few specializations of the above structs as possible.
We end up needing one specialization for
product_evaluator,binary_evaluator,Assignmentper assignment op (=,+=,-=), respectively. So not that bad actually!Tests
./runTests.py test/unit/math/rev/fun/eigen_hook_dispatch_test.cpp \ test/unit/math/rev/core/eigen_product_hook_test.cpp \ test/unit/math/rev/core/eigen_*_mre_test.cpp \ test/unit/math/mix/core/eigen_product_hook_kernels_tape_test.cpp \ test/unit/math/rev/fun/scale_multiply_test.cpp \ test/unit/math/rev/fun/multiply_add_test.cpp ./runTests.py test/unit/math/rev/core -f mre_testSide Effects
Any Eigen product with a
varoperand now goes through Stan's functions, including the ones Eigen forms inside its own kernels. I need to do a deeper analysis of Eigen's code to make sure they are not creating a Product expression anywhere and then accessing it element by element.Anytime we update Eigen versions we are going to be fixing even more internals. I think in the long run what we need to do is have Eigen do the same struct trick we do where they have an explicit template parameter that you can use to overload evaluators.
Release notes
Eigen expressions that multiply
varmatrices (A * B, element-wise products, scalar times matrix, andC.noalias() += A * B) now use Stan's reverse-mode functions, recording one node per product instead of one per scalar operation. Addsscale_multiplyandmultiply_add.Checklist
Copyright holder: Simons Foundation
The copyright holder is typically you or your assignee, such as a university or company. By submitting this pull request, the copyright holder is agreeing to the license the submitted work under the following licenses:
- Code: BSD 3-clause (https://opensource.org/licenses/BSD-3-Clause)
- Documentation: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
the basic tests are passing
./runTests.py test/unit)make test-headers)make test-math-dependencies)make doxygen)make cpplint)the code is written in idiomatic C++ and changes are documented in the doxygen
the new changes are tested