Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

C++ 20: trying to return std::ranges::view from a function

I am tasked with investigating performance improvement of some legacy C++ code, by leveraging CPP20. One potential area of improvement I see is this pattern in the hot path.

std::vector<engagementIndex> getEngagementsForDay(DayIndex day) {
  std::vector<engagementIndex> outputVector;
  for(EngagementIndex engagement : engagements){
    if(getDay(engagement) == day) {
      outputVector.pushback(engagement);
    }
  }
  return outputVector;
}

These are small vectors (less than 10 values typically) and the index it stores is just a short (not storing a whole object). Still this function gets called a few hundred thousand times, and I suspect that avoiding the vector memory allocation should help.

I want to replace the return type with a view instead.

Question #1: What is the return type for the appropriate view?

All the examples and discussions seem to utilize auto. At my tiny shop, auto is discouraged but if that indeed is the best option we can probably live with that.

But the bigger problem is that I can't even get my code to compile with auto.

Basically depending on some runtime conditions, I need to be able to return 4 "kinds" of views from the function.

An empty view

return std::ranges::empty_view<EngagementIndex>();

A whole vector

return engagements | std::views::all;

Part of a vector

return engagements | std::views::take(size);

Vector filtered by some condition

return engagements | std::views::filter(engagementDayFilter(day));

Each of my return statements produces a different type, preventing me from putting them in the same function.

What are my options?

I don't have any experience with CPP20, but google hasn't been too helpful.

EDIT: 10/1/24

I ended up going with an approach similar to one of the answers, but instead creating a custom single filter for each of my use cases. Effectively I am giving up on the ability to pipe filters. I am not qualified enough to say if this is better or worse, but I found it slightly easier to read.

    return engagements | std::views::filter(myFilter1(day));

But what I really wanted to share is that going from a vector copy to a view had a meaningful impact. My two target functions now are no longer on the hot path. I don't have a good way to measure the exact performance impact (our test cases don't naturally lend themselves), but I am fairly confident that the improvement is greater than 1% and less than 5%

like image 968
Dr Phil Avatar asked Jan 22 '26 22:01

Dr Phil


1 Answers

Each one of those views can have a different size, you cannot contain them all in some type-erased object without heap allocation, which is what you need in order to return it from a function. you can use a custom allocator or pmr::vector to reduce heap allocations.

Since you want to remove heap allocations to speed up performance then your best option is to use a std::variant of them all, you can use std::declval to help you declare the variant type.

using empty_engagement_view = std::ranges::empty_view<engagementIndex>;
using all_engagement_view = decltype(std::declval<std::vector<engagementIndex>&>() | std::views::all);
using partial_view = decltype(std::declval<std::vector<engagementIndex>&>() | std::views::take(std::declval<size_t>()));
using filtered_view = decltype(std::declval<std::vector<engagementIndex>&>() | std::views::filter(engagementDayFilter{std::declval<int>()})); 

using engagement_view_variant = std::variant<
    empty_engagement_view, 
    all_engagement_view, 
    partial_view, 
    filtered_view
    >;

there is a distinction between passing an rvalue and an lvalue to the view, the above ones only store lvalues, which means the vector must stay alive unit this view is destructed.

iterating over it is a bit tricky, you need to use std::for_each, this helper struct can help.

struct engagementView
{
    engagement_view_variant var;

    template <typename Func>
    void for_each(Func&& f)
    {
        std::visit([&](auto& view){
            std::for_each(view.begin(), view.end(), f);
        }, var);
    }
};

then you can just use it

int main()
{
    std::vector<engagementIndex> indicies;
    indicies.push_back(engagementIndex{1});
    indicies.push_back(engagementIndex{2});
    indicies.push_back(engagementIndex{3});


    engagementView view{indicies | std::ranges::views::all};
    view.for_each([](const auto& t) {
        std::cout << t.id << '\n';
    });
}

full godbolt demo

the biggest disadvantages are

  1. this doesn't compose, this only allows you to return those specific types from the function.
  2. the vector needs to outlive the view, and the vector cannot be moved, this is only a view of the vector that exists elsewhere.
  3. you need to list all the filters you expect if they are stateful, for stateless ones you can use a function pointer.

To solve those disadvantages you can create a type-erased wrapper that accepts any view that uses the heap or maybe a custom allocator .... which won't be much different than just returning a vector or a pmr::vector to start with.

like image 97
Ahmed AEK Avatar answered Jan 24 '26 12:01

Ahmed AEK



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!