Fix issue with Envoy not reference counting across scopes under not-hot restart#3249
Conversation
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
…ators Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
…ators Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
…uc/envoy into refcount-stats-in-heap-alloc Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
mrice32
left a comment
There was a problem hiding this comment.
Mostly looks good! Left a few nits.
source/common/stats/stats_impl.h
Outdated
| void free(RawStatData& data) override; | ||
|
|
||
| private: | ||
| StringRawDataMap stats_set_; |
There was a problem hiding this comment.
nit: since std::set is a different data structure entirely, can we name this something like stats_map_ or just stats_?
source/common/stats/stats_impl.h
Outdated
| class HeapRawStatDataAllocator : public RawStatDataAllocator { | ||
| public: | ||
| // RawStatDataAllocator | ||
| typedef std::unordered_map<std::string, RawStatData*> StringRawDataMap; |
There was a problem hiding this comment.
nit: since no other classes need to know about this typedef, do you think it makes sense to make it private?
source/common/stats/stats_impl.cc
Outdated
| return; | ||
| } | ||
|
|
||
| size_t key_removed = stats_set_.erase(std::string(data.key())); |
There was a problem hiding this comment.
We expect all stats to call freed before the allocator object disappears, so I would suggest adding a destructor with an ASSERT to check that the map is empty.
There was a problem hiding this comment.
@ambuc, took a quick look at the TSAN failure. Looks like we may need a lock protecting both methods (like the implementation in the hot restart allocator) because the calls to free(), which come from the destructors of the stat objects, are not protected at the callsite like the alloc() calls coming from the Scope are.
source/common/stats/stats_impl.cc
Outdated
| auto ret = stats_set_.insert(StringRawDataMap::value_type(std::string(key), nullptr)); | ||
| RawStatData*& data = ret.first->second; | ||
| if (ret.second) { | ||
| data = static_cast<RawStatData*>(::calloc(RawStatData::size(), 1)); |
There was a problem hiding this comment.
This looks correct, but it duplicates the required key storage in the map key.
If you make this a set<RawStatData*, RawStatDataHash, RawStatDataCompare> rather than a map, then the hasher and comparator could reference the key stored in the RawStatData, and available as a string_view via RawStatData::key().
Before calling set insertion you'd have to prospectively calloc the ptr and initialize() it, and then free it if turned out to be a dup. That seems better than duplicating the storage. And you'd have to make the trivial functors for hashing and comparison.
Then you could remove also the duplicated length check above, since it would be done in initialize().
There was a problem hiding this comment.
I think I +1'd too soon. It seems strange to alloc up an object just to be able to tell if the key exists, and then throwing it away if not. Duplicating the key or doing the truncation of the key before so we can check the set before allocating the object seems better IMO. It seems that now we've added a custom hash function, custom comparitor, and a somewhat complex set addition logic just to remove the duplicate storage of the key. Is there a particular reason that you think the way you suggested is more readable or performant?
There was a problem hiding this comment.
RawStatData::initialize() literally just does a memcpy of the bytes, so it's basically the same cost as making the prospective copy of the string you need to do the map lookup.
The syntax for making a custom hash/compare in STL is a little annoying, but I don't think it's that bad. I'm not following you about set-addition logic, I think it should be about equivalent, but it's (IMO) better to do the truncation in one place, and I can't judge exactly the cost of duplicating all stats at scale, but with this option it's zero :)
There was a problem hiding this comment.
SGTM. Not a huge fan of the additional complexity around the additional allocation logic and stl munging, but your perf point is reasonable. I'm don't think getting rid of these additional allocations on successful lookups would be worth all of the wasted memory in the normal map case. And that seems to be the only choices here.
Just a side note: we don't do the truncation in one place - we do it in two. key() truncates when the key is extracted. In the hot restart allocator, we do it three times: at the callsite, in initialize, and when the key is extracted. We should probably fix this in a later PR.
| RawStatData* stat_3 = alloc.alloc("not_ref_name"); | ||
| EXPECT_EQ(stat_1, stat_2); | ||
| EXPECT_NE(stat_1, stat_3); | ||
| EXPECT_NE(stat_2, stat_3); |
There was a problem hiding this comment.
let's just expect stat_3 is not nullptr too, though it looks like that would segv below anyway.
source/common/stats/stats_impl.cc
Outdated
| key.size(), Stats::RawStatData::maxNameLength()); | ||
| } | ||
|
|
||
| auto ret = stats_set_.insert(StringRawDataMap::value_type(std::string(key), nullptr)); |
There was a problem hiding this comment.
I think stats_set_ needs a mutex.
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
| data->initialize(name); | ||
| return data; | ||
| data->initialize(key); | ||
| auto ret = stats_.insert(data); |
There was a problem hiding this comment.
you can take the lock after the call to initialize(), to minimize the time spent holding the lock. Actually I think you can also let it go immediately after the call to insert as well as ref_count_ is atomic.
source/common/stats/stats_impl.cc
Outdated
| RawStatData* data = static_cast<RawStatData*>(::calloc(RawStatData::size(), 1)); | ||
| data->initialize(name); | ||
| return data; | ||
| data->initialize(key); |
There was a problem hiding this comment.
you can just pass 'name' in here, no need for the temp key.
Signed-off-by: James Buckland <jbuckland@google.com>
source/common/stats/stats_impl.cc
Outdated
| auto ret = stats_set_.insert(StringRawDataMap::value_type(std::string(key), nullptr)); | ||
| RawStatData*& data = ret.first->second; | ||
| if (ret.second) { | ||
| data = static_cast<RawStatData*>(::calloc(RawStatData::size(), 1)); |
There was a problem hiding this comment.
SGTM. Not a huge fan of the additional complexity around the additional allocation logic and stl munging, but your perf point is reasonable. I'm don't think getting rid of these additional allocations on successful lookups would be worth all of the wasted memory in the normal map case. And that seems to be the only choices here.
Just a side note: we don't do the truncation in one place - we do it in two. key() truncates when the key is extracted. In the hot restart allocator, we do it three times: at the callsite, in initialize, and when the key is extracted. We should probably fix this in a later PR.
source/common/stats/stats_impl.cc
Outdated
| // This must be zero-initialized | ||
| std::unique_lock<std::mutex> lock(mutex_); | ||
|
|
||
| absl::string_view key = name; |
There was a problem hiding this comment.
nit: I don't think you need this line anymore since you can just pass name to initialize directly (string_view will implicitly be constructed from a string IIUC).
|
|
||
| std::unique_lock<std::mutex> lock(mutex_); | ||
| auto ret = stats_.insert(data); | ||
| lock.unlock(); |
There was a problem hiding this comment.
There's a subtle problem here. The iterator you were returned can be invalidated if another element is inserted into the set. You need to grab the raw pointer from the iterator while locked, and never use the iterator again after unlocking.
There was a problem hiding this comment.
Oh very good point Matt. Just leave it locked till the end of the function then. I can't think of why the hash implementation would need to invalidate the iterator but if the standard doesn't say it is safe then there is no point risking it.
There was a problem hiding this comment.
I think the lock tightening is a good idea and will probably save us some cycles. We just need to extract the raw pointer from the iterator while locked and only use the pointer temporary after we unlock.
As for how this interacts with a custom hash, my basic understanding is that as the set grows, it will at some point decide to rehash (using the same hash function) the entire set onto a larger set of buckets. This is the only process that causes iterators to be invalidated for std::unordered_set.
Signed-off-by: James Buckland <jbuckland@google.com>
source/common/stats/stats_impl.cc
Outdated
|
|
||
| std::unique_lock<std::mutex> lock(mutex_); | ||
| auto ret = stats_.insert(data); | ||
| RawStatData* existingData = *ret.first; |
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
source/common/stats/stats_impl.cc
Outdated
| size_t key_removed = stats_.erase(&data); | ||
| lock.unlock(); | ||
|
|
||
| ASSERT(key_removed >= 1); |
There was a problem hiding this comment.
nit: Shouldn't this be == ?
Signed-off-by: James Buckland <jbuckland@google.com>
|
Mac test failure. I can't tell if it's related to this change or not. |
Signed-off-by: James Buckland <jbuckland@google.com>
htuch
left a comment
There was a problem hiding this comment.
Thanks for adding this, surprising how simple it ended up. A few questions.
source/common/stats/stats_impl.h
Outdated
| } | ||
| }; | ||
| typedef std::unordered_set<RawStatData*, RawStatDataHash_, RawStatDataCompare_> StringRawDataSet; | ||
| StringRawDataSet stats_; |
There was a problem hiding this comment.
Please add comments explaining what this is/does.
| }; | ||
| typedef std::unordered_set<RawStatData*, RawStatDataHash_, RawStatDataCompare_> StringRawDataSet; | ||
| StringRawDataSet stats_; | ||
| std::mutex mutex_; |
There was a problem hiding this comment.
Please add comments explaining what htis protects. Ideally we use GUARDED_BY etc. macros.
| return; | ||
| } | ||
|
|
||
| std::unique_lock<std::mutex> lock(mutex_); |
There was a problem hiding this comment.
Why do we need locking at all if the old comment about "This allocator does not ever have concurrent access to the raw data" hold true?
There was a problem hiding this comment.
The problem isn't the stat, it's the set.
There was a problem hiding this comment.
+1. Also, side note: that comment is no longer valid since the same stat can be freed/allocated multiple times, meaning that there may be cases where the allocator is operating on the same raw stat from two different threads.
There was a problem hiding this comment.
AFAICT we only ever do these allocations under existing locking, e.g.
. Do we need to be double locking here?I might be wrong in my assessment, please point out if not (and add a comment to the code!).
There was a problem hiding this comment.
Yes, good point, there probably need to be comments around this. The alloc() calls are protected, but the free() calls are made from the destructors of the individual stat objects. See https://github.com/envoyproxy/envoy/blob/master/source/common/stats/stats_impl.h#L310 for an example.
| data->initialize(name); | ||
| return data; | ||
|
|
||
| std::unique_lock<std::mutex> lock(mutex_); |
There was a problem hiding this comment.
Why are we allocating and then freeing on the case where we have an existing stat?
There was a problem hiding this comment.
Good question. Fundamentally it doesn't have to be this way, but this is an artifact of the way STL sets & maps work. STL set lookups require construction of an object. If you make this an map<string, RawStatData*> you have just pushed the problem around a little, as you'd need to copy the string to potentially truncate it, which is the same work, basically, as is being done here, and then you have to duplicate the truncation logic instead of just having it in RawStatData::initialize. Worse, you'd wind up permanently duplicating all the name storage. I argued that I don't know really how impactful that would be across different ways you might scale the system, but the current solution has zero overhead from duplication and is really no more complex from a programming perspective.
One question to ask is whether RawStatData::initialize is doing anything extra that's not required for the set lookup. It is, but it's pretty minimal and IMO not worth optimizing around.
An ideal solution would allow the set lookup against a string_view, without actually constructing the templated type. BlockMemoryHashSet::insert has that signature, so in the hot-restart case you don't need to do the prospective allocation.
There was a problem hiding this comment.
Yeah, fair enough. Maybe add a comment to the code capturing this design history. Thanks!
htuch
left a comment
There was a problem hiding this comment.
Clearing approved status for now.
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
source/common/stats/stats_impl.cc
Outdated
| // storing the name twice. Performing a lookup on the set is similarly | ||
| // expensive to performing a map lookup, since both require allocating a | ||
| // RawStatData object and a writing a string. | ||
|
|
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland jbuckland@google.com
title: Fixes issue with Envoy not reference counting stats across scopes under not-hot restart. Re-opened PR of #3212 due to a revert / DCO conflict.
Description: Simpler solution to issue #2453 than pull #3163, continuing draft work in ambuc#1 and ambuc#2. Summary of changes:
Risk Level: Low.
Testing: Add a test to stats_impl_test. Passes bazel test test/....
Docs Changes: N/A
Release Notes: This is user-facing in that non-hot restart stat allocation now resolves namespace properly, but no effect on user configs.
Fixes: #2453