[fft] with_len: owned length-adjusted copies with cache riding - #82
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
2b7d2c1 to
8efecdb
Compare
GCC Code Coverage Report📂 Overall coverage
|
…ision; poly divmod Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
…erify Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
…<= len(), cached when one lines up Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
…whole_operand/product_operand honor optional caches Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
…t() returns maybe_cached; drop redundant conversions Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
…ole cache, archetype tests - zero_extended is gone: span borrows are exactly len() again, no zero-tail fine print on the like contract. - with_len(s, n) returns resized<E>: an owned copy at logical length n carrying a reference to whichever of s's caches still serves it (the whole cache when extending, whatever first(n) carries when shrinking). - cache_opt_of -> cache_of; kth_term_of_rational_function seeds through it, so maybe_cached/cache_opt operands seed too. - prefix_cached models has_cache (prefix_cache(nextPow2(len())) is a whole cache), so whole-span ops stop re-transforming it into tmp; has_prefix_cache now keys on the prefix_cache member. - series.test.cpp: like-archetype instantiations of the generic algorithms, proving they use only contract expressions. - maybe_cached::cache_or was unused; removed. Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
5a6c05c to
246845f
Compare
Co-Authored-By: Andrew He <he.andrew.mail@gmail.com>
| resized<E> r; | ||
| r.s.assign(size_t(n), T{}); | ||
| std::span<const T> pc(p); | ||
| std::copy(pc.begin(), pc.end(), r.s.begin()); | ||
| r.f = detail::cache_of(p); | ||
| return r; |
There was a problem hiding this comment.
🔴 Length-adjusted copies can crash or silently compute wrong products after the original series is used again
The length-adjusted copy stores a raw reference to the original series' internal transform slot (r.f = detail::cache_of(p) at src/fft/series_core.hpp:300) instead of its own copy, so any later use of the original can make that reference point at freed or re-used memory.
Impact: A program that keeps a length-adjusted copy and then keeps using the original series can crash or produce silently wrong multiplication results.
Mechanism: prefix_cached stores its transforms in a std::vector that both reallocates and recomputes entries
prefix_cached keeps its memoized transforms in mutable std::vector<entry> caches (src/fft/series_core.hpp:624) and hands out entry::t references from prefix_cache(n) (src/fft/series_core.hpp:607-618). with_len copies coefficients but keeps a std::reference_wrapper to that slot in resized::f, which is then handed to E::extend_to by the multiply paths.
Two observed failures (both reproduced against engines::ntt<modnum<998244353>>):
-
Use-after-free.
auto w = series::with_len(qa, 4);takes a reference tocaches[2]; a subsequentseries::square(qa)callscache()->prefix_cache(nextPow2(len)), which doescaches.resize(...)and reallocates the vector. Usingwafterwards reports heap-use-after-free insidentt::extend_tounder ASan. -
Stale cache. For
qaof length 3,with_len(qa, 8)ridesprefix_cache(4), whose entry covers 3 coefficients. Afterqa.append(...)and a laterqa.first(4), that same slot is recomputed over 4 coefficients (c.len != echeck atsrc/fft/series_core.hpp:613), and the copy's product then comes out wrong even though the copy's own coefficients never changed.
The doc comment at src/fft/series_core.hpp:265-268 only requires the source to outlive the result, which is not sufficient: the source must additionally never touch its caches again.
Prompt for agents
series::with_len stores a std::reference_wrapper to a source cache slot in resized::f (src/fft/series_core.hpp:290-302). For prefix_cached sources that slot lives inside a mutable std::vector<entry> caches (src/fft/series_core.hpp:607-624), whose elements are both reallocated (caches.resize on a new power-of-two scale) and recomputed in place (when min(n, len()) changes after append()). Consequently a resized value that outlives a single expression can end up referencing freed memory (confirmed heap-use-after-free under ASan when square(qa) follows with_len(qa, 4)) or a transform of a different coefficient prefix (confirmed wrong product after append + first(4)). Consider either giving prefix_cached stable storage for its entries (e.g. std::deque, or vector of unique_ptr) plus an invalidation scheme that never rewrites an entry in place, or making resized own its transform (copy or shared handle) rather than borrowing, and update the contract comment to state precisely what the source may/may not do while the copy is alive.
Was this helpful? React with 👍 or 👎 to provide feedback.
| for (int n = 1; n < N; n *= 2) { | ||
| int m = 2 * n; | ||
| auto ta = E::transform(a.first(std::min(a.len(), m)), m); | ||
| auto ta = E::transform(a.first(std::min(N, m)), m); | ||
| auto tb = E::transform(std::span<const T>(b).first(n), m); |
There was a problem hiding this comment.
🟡 Series inversion of a prefix-cached input now does extra, discarded transforms
The inversion routine reads its input through the operand's own borrow helper (a.first(std::min(N, m)) at src/fft/series_core.hpp:385) instead of a plain coefficient view, so a prefix-caching input eagerly builds a transform at every doubling step that the routine immediately throws away.
Impact: Inverting a prefix-cached series is measurably slower (~30% in a 2^17-coefficient benchmark) and uses more memory than inverting the same plain series.
Mechanism: prefix_cached::first(k) has a transform-building side effect
Before this PR ps_inv started with span<E, false> a = a_;, so all subsequent first() calls went through the plain span. Now a is the generic operand: for prefix_cached, first(k) with k a power of two calls prefix_cache(k) (src/fft/series_core.hpp:597-603), which computes E::transform(s.first(e), 2 * k) and stores it. ps_inv then ignores that cache and computes E::transform(...) itself at size m, so each doubling level pays for one extra full-size forward transform.
Measured with engines::ntt<modnum<998244353>>, N = 2^17: 0.017s for trunc input vs 0.022s for the same coefficients wrapped in prefix_cached.
Prompt for agents
ps_inv (src/fft/series_core.hpp:373-399) now calls a.first(...) on the generic operand instead of first converting to span<E, false>. For prefix_cached operands, first(k) is not free: it populates and returns prefix_cache(k), an extra forward transform at size 2k that ps_inv never uses (it calls E::transform itself). Either take the plain span borrow up front for the read path, or actually reuse the returned cache (the TODO above ps_inv already contemplates populating/reusing these caches).
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Adds explicit length adjustment for series operands while keeping the
likecontract dense: a span borrow is exactlylen()coefficients, andfirst(n)(now part of the contract) requiresn <= len().series::with_len(s, n) -> resized<E>: an owned copy at logical length n (extending zero-fills, shrinking truncates). It carries a reference to a source cache when that cache still serves the copied coefficients (a zero tail doesn't change the transform), exposed viacache_opt(); the source must outlive the result.maybe_cached<E, exact>: the runtime counterpart ofcached_spanin the borrow hierarchyprefix_cached/cached -> maybe_cached/cached_span -> span.first(n)on cached types returns it, keeping the cache only when it still serves the whole borrow.detail::cache_of(s)(has_cache->cache(),has_cache_opt->cache_opt(), else nullopt), used byas_cached_span,product_operand, andkth_term_of_rational_functionseeding.prefix_cachedmodelshas_cache(cache()=prefix_cache(nextPow2(len())));has_prefix_cacheis keyed on theprefix_cache(n)member.prefix_cached::first(k)returnsmaybe_cached(real prefix cache when k lines up, nullopt otherwise).likesurface instantiates every generic algorithm, so off-contract expressions in template bodies fail at build time (concepts can't enforce this).An earlier revision of this PR prototyped a zero-copy
zero_extendedview with a "span borrows may be shorter than len(), tail is zero" contract; it was dropped because existing consumers reasonably assume a span borrow is the whole logical series, and the copy inwith_lenis O(n) against the O(n log n) FFT work while the cache (the expensive part) rides along.Unit suite green; Library Checker AC under g++ + sanitizer for inv/exp/log/pow, multipoint, interpolation, kth_term, convolution.
Link to Devin session: https://app.devin.ai/sessions/66a2d877f2354e30b3fdfa0c15061db2
Requested by: @ecnerwala