Skip to content

Use perfect forwarding for functions that use apply_*_* functions #3215

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 29 commits into
base: develop
Choose a base branch
from

Conversation

SteveBronder
Copy link
Collaborator

Summary

This fixes #3208 by using perfect forwarding for all functions that use our underlying apply family of functions for calling functions on containers and containers and containers. The issue was that the Holder class, when used in the apply functors, did not have enough type information to know which arguments it should take ownership of.

Consider the following function, where all types are passed in via constant reference.

template <typename T1, typename T2, require_any_container_t<T1, T2>* = nullptr>
inline auto gamma_p(const T1& a, const T2& b) {
  return apply_scalar_binary(
      [](const auto& c, const auto& d) { return gamma_p(c, d); }, a, b);
}

Calling this function with an Eigen expression that has a temporary in it would not give apply_scalar_binary and the Holder inside of apply_scalar_binary enough information to know that the Holder class should own any of the input arguments. As an example we can look at a simplified version of the code used in poisson_lccdf.hpp.

auto log_Pi = log(gamma_p(n_val + 1.0, lambda_val)));
double log_sum = sum(log_Pi);

gamma_p uses apply_scalar_binary and log uses apply_scalar_unary. We need to make sure the inputs and results of the gamma_p function do not fall out of scope by the time we go through log and then assign to log_Pi. Before this PR it would be possible for the expression n_val + 1.0 to fall out of scope as well as the result of gamma_p to go out of scope from log after log_Pi is assigned.

To combat this we now use perfect forwarding for all of the functions that use our internal apply family of functors. This should allow the Holder used internally by the apply functors to know which types need to be owned by it to make sure things do not fall out of scope.

Tests

There is no new tests for this. Since it is an isue on gcc I do wonder how we should test this in our CI/CD?

Side Effects

I'd like to think of some test we can write so that, in the future, developers do not accidentally write functions that use the apply family of functors that do not use perfect forwarding.

Release notes

Adds perfect forwarding to all functions that use the apply family of functors.

Checklist

  • Copyright holder: Steve Bronder

    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

    • unit tests pass (to run, use: ./runTests.py test/unit)
    • header checks pass, (make test-headers)
    • dependencies checks pass, (make test-math-dependencies)
    • docs build, (make doxygen)
    • code passes the built in C++ standards checks (make cpplint)
  • the code is written in idiomatic C++ and changes are documented in the doxygen

  • the new changes are tested

@SteveBronder SteveBronder marked this pull request as ready for review June 30, 2025 21:11
Copy link
Collaborator

@andrjohns andrjohns left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall big fan of the changes! Just a couple of general queries before a full review

log_softmax_alpha(k).d_ += negative_alpha_m_d_times_softmax_alpha_t_m;
inline auto log_softmax(T&& x) {
return apply_vector_unary<T>::apply(
std::forward<T>(x), [&](const auto& alpha) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
std::forward<T>(x), [&](const auto& alpha) {
std::forward<T>(x), [](auto&& alpha) {

The apply_vector_unary functors themselves should probably also perfect-forwarding, since they'll be passing their inputs to apply_* functions as well.

Also probably best to remove the reference-capture default while we're here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apply_vector_unary functors themselves should probably also perfect-forwarding, since they'll be passing their inputs to apply_* functions as well.

Can you clarify? I'm not seeing in this function how much forwarding can be done. I do the perfect forwarding in the actual code for apply_vector_unary etc if that is was you mean.

Also probably best to remove the reference-capture default while we're here

Agree

Comment on lines 35 to 37
static inline auto apply(const T2& x) {
return F::fun(x);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
static inline auto apply(const T2& x) {
return F::fun(x);
}
static inline auto apply(T2&& x) {
return F::fun(std::forward<T2>(x));
}

Should this also be forwarding? As the downstream calls are forwarding their arguments to apply_scalar_unary<*>::apply()

return apply_vector_unary<T>::apply(
y, [K](auto&& v) { return cholesky_corr_constrain(v, K); });
inline auto cholesky_corr_constrain(T&& y, int K) {
return apply_vector_unary<std::decay_t<T>>::apply(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not something you necessarily need to change in this PR, but it might be a bit cleaner to move the std::decay_t<T> handling into apply_vector_unary itself

@@ -43,7 +43,7 @@ inline plain_type_t<T> corr_constrain(const T& x) {
* @param[in,out] lp log density accumulator
*/
template <typename T_x, typename T_lp>
inline auto corr_constrain(const T_x& x, T_lp& lp) {
inline auto corr_constrain(T_x&& x, T_lp& lp) {
plain_type_t<T_x> tanh_x = tanh(x);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
plain_type_t<T_x> tanh_x = tanh(x);
plain_type_t<T_x> tanh_x = tanh(std::forward<T_x>(x));

@@ -34,7 +34,7 @@ namespace math {
*/
template <typename T, typename L, require_all_stan_scalar_t<T, L>* = nullptr,
require_all_not_st_var<T, L>* = nullptr>
inline auto lb_constrain(const T& x, const L& lb) {
inline auto lb_constrain(T&& x, const L& lb) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the the std::forward<T>(x) is missing from this function, and there are other container overloads in the file which probably need forwarding added as well

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So for functions that just operate on scalars I don't think we need to worry about forwarding as much since those functions will immediately evaluate

require_not_std_vector_t<M>* = nullptr>
inline auto offset_multiplier_free(const std::vector<T>& x, const M& mu,
const std::vector<S>& sigma) {
inline auto offset_multiplier_free(T&& x, const M& mu, S&& sigma) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think mu also needs to be forwarded here since it's passed to to_ref below

Comment on lines 62 to 63
divide(subtract(std::forward<T>(y), std::forward<M>(mu_ref)),
std::forward<S>(sigma_ref)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
divide(subtract(std::forward<T>(y), std::forward<M>(mu_ref)),
std::forward<S>(sigma_ref)));
divide(subtract(std::forward<T>(y), std::forward<decltype(mu_ref)>(mu_ref)),
std::forward<decltype(sigma_ref)>(sigma_ref)));

Comment on lines +76 to +77
auto&& mu_ref = to_ref(std::forward<M>(mu));
auto&& sigma_ref = to_ref(std::forward<S>(sigma));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these also be forwarded in the offset_multiplier_free call below?

lp += log_inv_logit_x + log1m_inv_logit(x);
return exp(log_inv_logit_x);
inline auto prob_constrain(T&& x, return_type_t<T>& lp) {
plain_type_t<T> log_inv_logit_x = log_inv_logit(x);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
plain_type_t<T> log_inv_logit_x = log_inv_logit(x);
plain_type_t<T> log_inv_logit_x = log_inv_logit(std::forward<T>(x));

@WardBrian
Copy link
Member

WardBrian commented Jul 1, 2025

Re #3208, this branch fixes the issue in test/prob/poisson/poisson_ccdf_log_00000_generated_v_test, but I'm still getting ASAN failures in test/prob/loglogistic/loglogistic_cdf_00001_generated_ffv_test on line 116 of loglogistic_cdf.hpp

= -multiply_log(alpha_div_y_pow_beta, alpha_div_y) * prod_all_sq;
partials<2>(ops_partials) = beta_deriv * cdf_div_elt;

Could be related to #3147?

@WardBrian
Copy link
Member

I do think we should weigh the odds that merging something of this scope during a release window would introduce more issues than it resolves (especially because the issues seem to have been present in the code for a couple years)

@SteveBronder
Copy link
Collaborator Author

I agree this is a large PR. Once I found the bug in one place I realized it was kind of systematic to all of our functions that can take in an expression and then output an expression. Let's talk irl. I agree this is risky before a release, but also think the bug is kind of large and can also happen in user space

@WardBrian
Copy link
Member

Not denying it can happen to users, but as far as we know it hasn’t in the couple years it has been present. So, would it be that bad to hold the fix a couple months for the next release

Anyway, especially if @andrjohns thinks he can review it, I’m not opposed to merging, just wanted to ask in the name of due diligence

@SteveBronder
Copy link
Collaborator Author

Yes I agree that it feels risky. But this is a very odd bug and imo I don't feel great about having this in a release when we know it is there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Undefined behavior in combination of apply_scalar_binary/make_holder/to_ref_if
5 participants