Follow-ups to #593: iterator interop, checked Expr::at(), hash invalidation - #594
Open
evaleev wants to merge 5 commits into
Open
Follow-ups to #593: iterator interop, checked Expr::at(), hash invalidation#594evaleev wants to merge 5 commits into
evaleev wants to merge 5 commits into
Conversation
ExprIteratorImpl's heterogeneous operator-/operator==/operator<=> read `other.ptr_` of the *other* specialization, which is private and had no friend declaration, so every one of them was a hard error the moment it was instantiated. Nothing in tree mixed the two iterator types, so this went unnoticed: `expr.begin() != expr.cend()`, or pairing sequant::cbegin(ExprPtr const&) with sequant::end(ExprPtr&), failed to compile. There was also no ExprIterator -> ConstExprIterator conversion. - befriend all ExprIteratorImpl specializations and collapse each pair of <is_const>/<!is_const> overloads into a single member template - add a converting constructor (mutable -> const only); it is a constructor template so that it is never treated as a copy constructor - drop `operator-(difference_type, ExprIteratorImpl)`: `n - it` is not a valid random-access-iterator expression, and it silently computed `it - n` (the `n + it` counterpart is valid and stays) - pin all of the above down with static_asserts next to the existing random_access_iterator ones
Dropping ranges::view_interface also dropped its at(), which threw when the index was out of range. The replacement forwards to operator[], whose only guard is SEQUANT_ASSERT -- a no-op unless SEQUANT_ASSERT_ENABLED is #defined. So in a build configured with SEQUANT_ASSERT_BEHAVIOR=IGNORE, `sum.at(p)` (e.g. optimize/sum.cpp:118) degraded from a thrown exception to an out-of-bounds read returning a garbage ExprPtr&. The parameter also went from a signed difference type to std::size_t, so at(-1) went from throwing to wrapping to SIZE_MAX. back() had the same problem from the other end: at(size() - 1) on an empty Expr -- every atom -- computes at(SIZE_MAX). at() now always checks and throws sequant::Exception; the cold throwing path stays out of line so it does not bloat callers. operator[] keeps its assert-only check, now documented as unchecked. N.B. the exception type is sequant::Exception rather than the std::out_of_range the range-v3 at() used to throw. Nothing in tree catches std::out_of_range from here, and sequant::Exception is the project convention.
begin/end/cbegin/cend/size/empty/operator[]/at/front/back are one-line forwarders on the hottest paths in the library. Expr::is_atom() alone is called from visit_impl(), is_scalar(), is_cnumber(), ExprRange::next_atom() and the Wick/canonicalization code, and out-of-line definitions turn each of these into a non-inlinable cross-TU call on top of the virtual dispatch they already pay for. Also make empty() compare begin/end rather than compute size() == 0, saving a pair of virtual calls. This is a restoration, not a new decision: before the switch away from ranges::view_interface these were all header-inline (as CRTP base templates), and is_atom() was `ranges::empty(*this)`. Measured, Release/-O3, SEQUANT_ASSERT_BEHAVIOR=IGNORE, Apple clang 17, against the identical tree with these bodies moved back to expr.cpp, 3 interleaved rounds of 3 repetitions each, comparing per-benchmark medians over sequant_benchmarks (canonicalize, simplify, rapid_simplify, spintrace, tensor_block, random_tensor_network): 53 of 53 benchmarks faster; median -2.29%, mean -2.26% best -4.31% (rapid_simplify/1), worst -0.09% No code-size cost -- the trivial bodies inline away rather than bloat: libSeQuant-symb.a 7437952 B inline vs 7441224 B out-of-line sequant_benchmarks 4985824 B inline vs 4986816 B out-of-line Mechanism, for the record: out-of-line, libSeQuant-symb.a alone carries 34 undefined cross-TU references to these accessors and emits 16 definitions for them; inline, it has zero of either. N.B. cc_full_derivation was excluded from the benchmark set -- it segfaults, but it does so on unmodified master too, so it is unrelated to this branch.
Product::end_cursor() used to call reset_hash_value(); the end_subexpr() that replaced it does not, so `*(--product.end()) = new_factor` mutates a factor while leaving the memoized hash in place. That trips the `*hash_value_ == compute_hash()` assert in Product::memoizing_hash(), and with asserts disabled leaves a stale hash that makes static_equal() short-circuit to false for products that are in fact equal. Sum::begin_subexpr() gained the reset the old Sum::begin_cursor() never had, which is the right call -- handing out a mutable iterator into the storage has to invalidate the hash. Give Sum::end_subexpr() the same treatment so both ends of both containers agree.
Regression tests for the three fixes in this branch. Each fails without its fix: - "mixed const/non-const iteration" does not compile at all without the friend declaration and the converting constructor - "checked element access" fails in a build with SEQUANT_ASSERT_BEHAVIOR=IGNORE, where at() used to read out of bounds instead of throwing (with asserts enabled the assert masks it) - "hash invalidation on mutable iteration" fails on both Product and Sum when only begin_subexpr() resets the memoized hash
evaleev
force-pushed
the
evaleev/fix/expr-range-followups
branch
from
August 20, 2026 15:53
1ac05e4 to
b20c46d
Compare
Collaborator
|
I don't think it is good practice to move implementations into header files unless crucial for performance. Enabling LTO seems like the better way to address potential performance bottlenecks from these sorts of things 🤔 |
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.
Follow-up to #593, which turned
Exprinto a proper range. Three defects that CI could not catch, plus regression tests for each, plus one performance restoration.Each commit stands alone, so the perf commit (
27f02258) can be dropped without touching the correctness fixes.1. const/non-const iterators do not interoperate at all
ExprIteratorImpl's heterogeneousoperator-/operator==/operator<=>readother.ptr_of the other specialization, which is private, with nofrienddeclaration anywhere. Every one of those overloads is a hard error the moment it is instantiated:Nothing in tree currently mixes the two types, which is why master builds. But
expr.begin() != expr.cend()does not compile, and neither does pairingsequant::cbegin(ExprPtr const&)(expr_algorithms.hpp:65) with the non-constsequant::end(ExprPtr&)(expr_algorithms.hpp:78). There was also noExprIterator→ConstExprIteratorconversion, so the const/non-const interop these overloads exist to provide was entirely non-functional.Fixed by befriending all specializations, collapsing each
<is_const>/<!is_const>overload pair into one member template, and adding a converting constructor (mutable → const only; it is a constructor template so it is never treated as a copy constructor). Also droppedoperator-(difference_type, ExprIteratorImpl)—n - itis not a valid random-access-iterator expression and it silently computedit - n. The validn + itcounterpart stays.2.
Expr::at()lost its bounds checkDropping
ranges::view_interfacealso dropped itsat(), which threw when the index was out of range. The replacement forwards tooperator[], whose only guard isSEQUANT_ASSERT— a no-op unlessSEQUANT_ASSERT_ENABLEDis#defined. In a build configured withSEQUANT_ASSERT_BEHAVIOR=IGNORE,sum.at(p)(optimize/sum.cpp:118) degrades from a thrown exception to an out-of-bounds read returning a garbageExprPtr&. The parameter also changed from a signed difference type tostd::size_t, soat(-1)went from throwing to wrapping toSIZE_MAX.back()had the same problem from the other end:at(size() - 1)on an emptyExpr— every atom — computesat(SIZE_MAX).at()now always checks and throwssequant::Exception(project convention; nothing in tree catchesstd::out_of_rangefrom here). The throwing path is out of line so it does not bloat callers.operator[]keeps its assert-only check, now documented as unchecked.3.
Product::end_subexpr()no longer invalidates the memoized hashProduct::end_cursor()used to callreset_hash_value(); theend_subexpr()that replaced it does not. So*(--product.end()) = new_factormutates a factor while leaving the memoized hash in place — which trips the*hash_value_ == compute_hash()assert inProduct::memoizing_hash(), and with asserts disabled leaves a stale hash that makesstatic_equal()short-circuit tofalsefor products that are in fact equal.Sum::begin_subexpr()gained the reset the oldSum::begin_cursor()never had, which is the right call.Sum::end_subexpr()gets it too, so both ends of both containers agree.4. Hot accessors moved back into the header (separate commit,
27f02258)begin/end/cbegin/cend/size/empty/operator[]/at/front/backare one-line forwarders on the hottest paths in the library.Expr::is_atom()alone is called fromvisit_impl(),is_scalar(),is_cnumber(),ExprRange::next_atom()and the Wick/canonicalization code; out-of-line definitions turn each into a non-inlinable cross-TU call on top of the virtual dispatch they already pay for.empty()now comparesbegin/endrather than computingsize() == 0, saving a pair of virtual calls.This is a restoration, not a new decision — before the switch away from
ranges::view_interfacethese were all header-inline (as CRTP base templates), andis_atom()wasranges::empty(*this).Measured rather than asserted. Release/
-O3,SEQUANT_ASSERT_BEHAVIOR=IGNORE, Apple clang 17, against the identical tree with these bodies moved back toexpr.cpp— 3 interleaved rounds of 3 repetitions, comparing per-benchmark medians oversequant_benchmarks(canonicalize,simplify,rapid_simplify,spintrace,tensor_block,random_tensor_network):rapid_simplify/1) / −0.09%No code-size cost — the trivial bodies inline away rather than bloat:
libSeQuant-symb.asequant_benchmarksMechanism, for the record: out-of-line,
libSeQuant-symb.aalone carries 34 undefined cross-TU references to these accessors and emits 16 definitions for them; inline it has zero of either.cc_full_derivationwas excluded from the benchmark set: it segfaults, but it does so on unmodified master (3168a655) too, so it is unrelated to this branch — reported separately.Verification
Each new test fails without its fix:
mixed const/non-const iteration'ptr_' is a private member,no viable conversion)checked element accessREQUIRE_THROWS_AS(sum->at(2), Exception)fails withSEQUANT_ASSERT_BEHAVIOR=IGNOREhash invalidation on mutable iterationProductandSumFull unit suite run locally in two configurations:
SEQUANT_ASSERT_BEHAVIOR=THROW: 6750 assertions in 62 test cases, all passedSEQUANT_ASSERT_BEHAVIOR=IGNORE: 310895 assertions in 63 test cases, all passedCI is green on all 16 checks — GCC 14 and clang, Debug and Release, sanitizers, Valgrind.