Skip to content

Fix issue with Envoy not reference counting across scopes under not-hot restart#3249

Merged
htuch merged 34 commits intoenvoyproxy:masterfrom
ambuc:refcount-stats-in-heap-alloc
May 4, 2018
Merged

Fix issue with Envoy not reference counting across scopes under not-hot restart#3249
htuch merged 34 commits intoenvoyproxy:masterfrom
ambuc:refcount-stats-in-heap-alloc

Conversation

@ambuc
Copy link
Contributor

@ambuc ambuc commented Apr 27, 2018

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:

  • adds an unordered_map named stats_set_ as a member variable of HeapRawStatDataAllocator, and implements reference counting / dedup on allocated stats.

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

ambuc added 18 commits April 25, 2018 16:40
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>
Copy link
Member

@mrice32 mrice32 left a comment

Choose a reason for hiding this comment

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

Mostly looks good! Left a few nits.

void free(RawStatData& data) override;

private:
StringRawDataMap stats_set_;
Copy link
Member

Choose a reason for hiding this comment

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

nit: since std::set is a different data structure entirely, can we name this something like stats_map_ or just stats_?

class HeapRawStatDataAllocator : public RawStatDataAllocator {
public:
// RawStatDataAllocator
typedef std::unordered_map<std::string, RawStatData*> StringRawDataMap;
Copy link
Member

Choose a reason for hiding this comment

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

nit: since no other classes need to know about this typedef, do you think it makes sense to make it private?

return;
}

size_t key_removed = stats_set_.erase(std::string(data.key()));
Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Member

@mrice32 mrice32 left a comment

Choose a reason for hiding this comment

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

@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.

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));
Copy link
Contributor

Choose a reason for hiding this comment

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

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().

Copy link
Member

Choose a reason for hiding this comment

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

+1.

Copy link
Member

@mrice32 mrice32 May 1, 2018

Choose a reason for hiding this comment

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

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?

Copy link
Contributor

@jmarantz jmarantz May 1, 2018

Choose a reason for hiding this comment

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

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 :)

Copy link
Member

Choose a reason for hiding this comment

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

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);
Copy link
Contributor

Choose a reason for hiding this comment

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

let's just expect stat_3 is not nullptr too, though it looks like that would segv below anyway.

key.size(), Stats::RawStatData::maxNameLength());
}

auto ret = stats_set_.insert(StringRawDataMap::value_type(std::string(key), nullptr));
Copy link
Contributor

Choose a reason for hiding this comment

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

I think stats_set_ needs a mutex.

ambuc added 3 commits April 30, 2018 15:22
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);
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

RawStatData* data = static_cast<RawStatData*>(::calloc(RawStatData::size(), 1));
data->initialize(name);
return data;
data->initialize(key);
Copy link
Contributor

Choose a reason for hiding this comment

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

you can just pass 'name' in here, no need for the temp key.

Signed-off-by: James Buckland <jbuckland@google.com>
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));
Copy link
Member

Choose a reason for hiding this comment

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

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.

// This must be zero-initialized
std::unique_lock<std::mutex> lock(mutex_);

absl::string_view key = name;
Copy link
Member

Choose a reason for hiding this comment

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

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();
Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

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>

std::unique_lock<std::mutex> lock(mutex_);
auto ret = stats_.insert(data);
RawStatData* existingData = *ret.first;
Copy link
Member

Choose a reason for hiding this comment

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

nit: existing_data

ambuc added 4 commits May 1, 2018 17:15
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>
Copy link
Member

@mrice32 mrice32 left a comment

Choose a reason for hiding this comment

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

LGTM - thanks!

Copy link
Member

@mattklein123 mattklein123 left a comment

Choose a reason for hiding this comment

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

LGTM, just small nit/Q

size_t key_removed = stats_.erase(&data);
lock.unlock();

ASSERT(key_removed >= 1);
Copy link
Member

Choose a reason for hiding this comment

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

nit: Shouldn't this be == ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, good catch. Fixed in 87318c0.

Signed-off-by: James Buckland <jbuckland@google.com>
mattklein123
mattklein123 previously approved these changes May 2, 2018
@ggreenway
Copy link
Member

Mac test failure. I can't tell if it's related to this change or not.

[ RUN      ] TestParameters/UdsUpstreamIntegrationTest.RouterDownstreamDisconnectBeforeResponseComplete/0
[2018-05-02 16:10:36.593][155368][critical][assert] source/common/stats/thread_local_store.cc:103] assert failure: !merge_in_progress_
[2018-05-02 16:10:36.593][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:114] Caught Abort trap: 6, suspect faulting address 0x7fff759f7e3e
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:87] Backtrace obj<uds_integration_test> thr<123145453625344>:
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:99] thr<123145453625344> obj<uds_integration_test                0x000000010e6ff1b6 _ZN8backward7details6unwindINS_14StackTraceImplINS_10system_tag10darwin_tagEE8callbackEEEmT_m>
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:105] thr<123145453625344> #0 0x10e6ff1b6: 
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:99] thr<123145453625344> obj<uds_integration_test>
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:105] thr<123145453625344> #1 0x10e6fed25: backward::StackTraceImpl<backward::system_tag::darwin_tag>::load_here(unsigned long) + 101
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:105] thr<123145453625344> #2 0x10e6feb21: backward::StackTraceImpl<backward::system_tag::darwin_tag>::load_from(void*, unsigned long) + 49
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:105] thr<123145453625344> #3 0x10e6fd07e: Envoy::BackwardsTrace::captureFrom(void*) + 46
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:105] thr<123145453625344> #4 0x10e6fcf3f: Envoy::SignalAction::sigHandler(int, __siginfo*, void*) + 143
[2018-05-02 16:10:36.605][155368][critical][backtrace] bazel-out/darwin-fastbuild/bin/source/server/_virtual_includes/backtrace_lib/server/backtrace.h:110] end backtrace thread 123145453625344

Signed-off-by: James Buckland <jbuckland@google.com>
Copy link
Member

@htuch htuch left a comment

Choose a reason for hiding this comment

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

Thanks for adding this, surprising how simple it ended up. A few questions.

}
};
typedef std::unordered_set<RawStatData*, RawStatDataHash_, RawStatDataCompare_> StringRawDataSet;
StringRawDataSet stats_;
Copy link
Member

Choose a reason for hiding this comment

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

Please add comments explaining what this is/does.

};
typedef std::unordered_set<RawStatData*, RawStatDataHash_, RawStatDataCompare_> StringRawDataSet;
StringRawDataSet stats_;
std::mutex mutex_;
Copy link
Member

Choose a reason for hiding this comment

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

Please add comments explaining what htis protects. Ideally we use GUARDED_BY etc. macros.

return;
}

std::unique_lock<std::mutex> lock(mutex_);
Copy link
Member

Choose a reason for hiding this comment

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

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?

Copy link
Contributor

Choose a reason for hiding this comment

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

The problem isn't the stat, it's the set.

Copy link
Member

Choose a reason for hiding this comment

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

+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.

Copy link
Member

Choose a reason for hiding this comment

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

AFAICT we only ever do these allocations under existing locking, e.g.

SafeAllocData alloc = parent_.safeAlloc(final_name);
. 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!).

Copy link
Member

@mrice32 mrice32 May 3, 2018

Choose a reason for hiding this comment

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

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_);
Copy link
Member

Choose a reason for hiding this comment

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

Why are we allocating and then freeing on the case where we have an existing stat?

Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, fair enough. Maybe add a comment to the code capturing this design history. Thanks!

Copy link
Member

@htuch htuch left a comment

Choose a reason for hiding this comment

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

Clearing approved status for now.

Signed-off-by: James Buckland <jbuckland@google.com>
ambuc and others added 3 commits May 3, 2018 10:48
Signed-off-by: James Buckland <jbuckland@google.com>
Signed-off-by: James Buckland <jbuckland@google.com>
// 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.

Copy link
Member

Choose a reason for hiding this comment

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

nit: extra line.

Signed-off-by: James Buckland <jbuckland@google.com>
Copy link
Member

@htuch htuch left a comment

Choose a reason for hiding this comment

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

Thanks!

@htuch htuch merged commit 795848b into envoyproxy:master May 4, 2018
@ambuc ambuc deleted the refcount-stats-in-heap-alloc branch May 7, 2018 12:24
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.

!ENVOY_HOT_RESTART does not reference count across scopes

6 participants