-
Notifications
You must be signed in to change notification settings - Fork 43
Generic flat response implementation #278
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
base: develop
Are you sure you want to change the base?
Conversation
0d454ef
to
7e7e3dd
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've reviewed the flat_response_impl part. It looks what I'd expect. Marcelo is more suited to review the rest, since I don't really know how adapters work. Thanks for submitting the patch.
@anarthal It's not really ready, just wanted to make sure that I'm on right direction. Thank you for your review! |
Hi @D0zee, I've looked at this PR again and think it has a good direction so I endorse further development. The two issues I talked about in the issue are minor and can be addressed in the future if at all, i.e.
Thanks again for the time invested. |
Hi @mzimbres, I'm going to look at suggested issues in details and implement them to enhance performance and make the structure more user-friendly. Thank you for your comments in the issue |
struct impl_t { | ||
fn_type adapt_fn; | ||
adapt_fn_type adapt_fn; | ||
done_fn_type prepare_done_fn; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would like to avoid the creation of two std::function
for each request (i.e. async_exec
calls) since each one represents a potential dynamic allocation. In this case however I think we can't do much better because inside async_exec
we only have access to the type erased adapter i.e. std::function
and therefore we can't call adapter.set_done()
for example. I think the prepare_done_fn
callback is not critical though since it is small and the dynamic allocation is likely to be optimized away with SOO (small object optimization). @anarthal What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sizeof(generic_flat_response*)=8
sizeof(generic_flat_response&)=64
sizeof([](){})=1
-
If response type is
generic_flat_response
we will return lambda with captured reference ([res]()mutable{}
). The size of this lambda is 64 bytes,std::function
will definitely keep it on heap. I suggest to capture and pass the pointergeneric_flat_response*
. Its size is 8 bytes and in this case SOO will take place during creation ofstd::function
inany_adapter
. As a result we have no heap allocations in this case. -
If the response type is any other SOO will take a place by default due to the fact that size of empty lambda is 1 byte.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have been thinking about this and there seems to be a simpler way of avoiding the extra std::function
that I haven't considered earlier. First we define a special node value in node.hpp
constexpr resp3::node_view done_node{resp3::type::invalid, -1, -1, {}};
then call the adapter with this node when the parser is done here
template <class Adapter>
bool parse(resp3::parser& p, std::string_view const& msg, Adapter& adapter, system::error_code& ec)
{
while (!p.done()) {
...
}
// ---> New adapter call here to inform parsing is finished.
adapter(std::make_optional<resp3::parser::result>(done_node), system::error_code());
return true;
}
Then call set_views()
on the adapter when this node is detected here
template <>
class general_aggregate<result<flat_response_value>> {
private:
result<flat_response_value>* result_;
public:
template <class String>
void operator()(resp3::basic_node<String> const& nd, system::error_code&)
{
// ---> Check whether done here.
if (nd == done_node) {
result_->value().set_views();
return;
}
...
}
};
I totally missed this possibility earlier but it looks cleaner than adding a new std::function
? Do you mind trying this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it looks much better and we don't have to care about heap allocations. I will try it!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Am I correct that this case must be handled by others adapters as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @D0zee, yes other adapters will have to handle this as well, for example
if (nd == done_node)
return;
I forgot to say this in my previous comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi again, there is still one problem to solve. The way I suggested above will cause the set_view
to be called multiple times, once for each request in the pipeline. That is because the same flat response can be used to store the response of multiple requests. I am still not sure what is the simplest way to avoid that, it can be solved by adding state to set_views()
so that it does not traverse what has already been traversed.
Or perhaps there is a way to wrap the adapters at other location such as here
template <class T>
static auto create_impl(T& resp) -> impl_t
{
using namespace boost::redis::adapter;
auto adapter = boost_redis_adapt(resp);
std::size_t size = adapter.get_supported_response_size();
return {std::move(adapter), size};
}
or here
adapter_ = [this, adapter](resp3::node_view const& nd, system::error_code& ec) {
auto const i = req_->get_expected_responses() - remaining_responses_;
adapter(i, nd, ec);
};
so that only the last call to adapter(done_node, ec)
triggers a set_view()
. I think however this might be too complicated and perhaps it just simpler to let each call to set_view to traverse only what has not been set yet, as suggested above.
If I have time I will review all of this to see if there is any simplification possible.
I might be late to the discussion, but the new API looks more inconvenient than what was proposed initially, leaking much many implementation details and requiring workarounds in the adapters. I guess there's a strong reason performance-wise to go this way. Do we have measures on how faster this approach is vs. the more convenient one? |
@anarthal There are two problems to solve
Number 1. is the easier part and I would like to have it equal to Number 2. is what is causing problems. I am still trying to understand what is cleaner from the design and performance point of view and in a way that doesn't break existing code. We have already two callbacks associated with I think the third option is the only clean and sound option because it makes sense to know when parsing is complete from the adapter. But once we have that the Given this complexity I think it would be simpler to split this PR in two. @D0zee works only on 1. and I will work on part 2. so he has a sane way of calling |
@mzimbres Let me please know when your part is ready. I think I will finish my part ( |
Note that the done callback currently belongs to the I/O world (it interacts with a channel) rather than the parser world. IMO long-term the callback should be replaced by a reader action (as I think was your intention according to this comment). Semantically, having adapters support a "done" callback looks sound to me. But as you said, there is the type erasing issue. If you want, I can try to write a type-erased adapter type that encapsulates both functions, something like: class any_adapter_impl {
// stores the underlying adapter, akin to what std::function does for functions
public:
void on_node(std::size_t, resp3::node_view const&, system::error_code&);
void on_finished();
}; Then you can use this type in |
Yeah, that is an important realization. Extending the adapter with
I think we need three functions
There is a simple way to deal with that. We can add a new parameter to the adapter, currently we have template <class String>
void operator()(resp3::basic_node<String> const&, system::error_code&); which could be changed to enum class event { start, node, finish};
template <class String>
void operator()(event ev, resp3::basic_node<String> const& nd, system::error_code& ev)
{
switch (ev) {
case start: adapter.on_start(); return;
case node: adapter.on_node(nd, ev); return;
case finish: adapter.on_finish(); return;
}
} That would be a breaking change but probably nobody is writing adapters.
You are welcome, the adapter module has its complexity so feel free to ask. Also, please create a sub-issue in the corresponding ticket.
Note that request req
req.push("COMMAND1", ...);
req.push("COMMAND2", ...);
req.push("COMMAND3", ...);
response<T1, T2, T3> resp;
co_wait conn.async_exec(request, resp);
// on_finish will have been called three times when we get here, once
// for each response element. I am noticing how much background knowledge this implementation requires, I should not have expected @D0zee to go through all these details, apologies. |
Hi @mzimbres, thank you for letting me know! I will tweak PR this week |
Hi guys, can you please have one more look at PR? After rebase I've replaced almost all |
void on_done() | ||
{ | ||
if (result_->has_value()) { | ||
result_->value().set_view(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a request contains multiple commands, on_done()
and by consequence set_view()
will be called multiple times, although it should be called only once on the last on_done()
call. I have to review the code again to understand whether we know upfront how many times on_done()
will be called. Traversing the vector of nodes every time is prohibitively expensive.
This might get more complex when reading server pushes with async_receive since we don't really know how many we are expecting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually I have not thought about it, I will consider this case as well
Hi @D0zee, thanks again. I did not have the time to review last week. While reading the code now it occurs me we need a way to avoid calling I might no have the time next week, but after that I do, so please have some patience. Thanks. |
Hi again, regarding the problem of calling I think I also oversaw this problem in the first stages of this PR that used the set_done_callback, that only works for request/response but not for server pushes. In summary, it might be better to delegate the Thoughts? |
Pushing the responsibility to the user seems hostile. The library seems the right place to do it. If the On the other hand, why was the idea of creating the |
After some days thinking I believe there is a good solution that just requires making
void set_view()
{
for (; pos_ < view_.size(); ++pos_) {
auto& v = view_.at(pos_).value;
v.data = std::string_view{data_.data() + v.offset, v.size};
}
}
void add_node(resp3::basic_node<String> const& nd)
{
auto capacity_before = data_.capacity();
add_node_impl(nd);
auto capacity_after = data_.capacity();
if (capacity_after > capacity_before)
pos_ = 0;
} That means, every time @D0zee Thanks for keeping up so far, this PR has been very helpful in detecting open points in the original design. If you don't have the time to implement the suggestion above I can take over it, I think however this is not much work. Thanks. |
@criatura2 Thank you for your proposal, but I think the similar problem was discussed at some moment of time in this PR and we denied that. The intention is to call I agree with @anarthal that the responsibility of setting view must be rather on library than on client. I will spend some time thinking about this problem on FSM side. |
What is not ok is to traverse the whole vector for each |
You are right, that is the next step I'm going to look at. It was noticed here #278 (comment). Ideally we would like to call I suspect your solution to be if (capacity_after > capacity_before)
pos_ = 0; @criatura2 If you would like to help you can check the comments below and think about the case with server pushes when we don't know how many messages to expect (details are here: #278 (comment)). Next week I'm going to get deeper and try to come up with solution. |
If the response is reused such as here only one read will be generic_flat_response resp;
resp.value().reserve(some_number); Will bring reallocations to negligible levels. |
You have come up to the same problem - how to learn the number of responses we are going to handle. The problem is that in your approach we also need to know what is the average or expected size of each response. In my opinion it is impossible to know in advance because it depends on the content stored in redis-server. In suggested below approach to call |
There is no real difference between calling Do you agree that the number of reallocations is negligible? |
What if we indeed move the responsibility to call |
Are you proposing the following scheme? iterator begin() const {
if (!views_set_) {
set_views();
views_set_ = true;
}
return /* whatever */
} |
@anarthal yes, exactly. In our case this check will be at the beginning of this methods of ![]() |
Hm. This has a number of problems:
I want to understand why this can't be done after parsing is finished - is it because of pushes? For regular requests, it looks like the points where parsing starts and ends are clearly defined. |
Also, if the problem are indeed pushes, what are the plans to implement |
Yes. The default size of the channel used to deliver pushes is 256, that means |
@D0zee sorry for late reply, I was very busy these days. As I said earlier (in my other account criatura2). Detecting allocations is close to optimal IMO. I don't like the idea of safe-guarding every function with an if. |
Hi @mzimbres, didn't know that account is yours! I need to think about it one more time. Sorry I was busy this week as well. Thank you for bearing with me! |
Hi @mzimbres, can you please take over that change? I understand that I've been extremely busy recently and it stops this PR to be merged. |
No description provided.