This is an automated email from the ASF dual-hosted git repository.
chenBright pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/brpc.git
The following commit(s) were added to refs/heads/master by this push:
new bc010e7c Support higher performance histogram (#3561)
bc010e7c is described below
commit bc010e7c89560cdd1d671e35686b4e49be541cb3
Author: Bright Chen <[email protected]>
AuthorDate: Wed Sep 23 10:54:10 2026 +0800
Support higher performance histogram (#3561)
* Support higher performance histogram
* Fix some issues
---
BUILD.bazel | 5 +-
src/bvar/detail/sampler.h | 35 ++++----
src/bvar/histogram.cpp | 16 ++++
src/bvar/histogram.h | 155 +++++++++++++++++++++++++++++++++---
src/bvar/passive_status.h | 7 +-
test/bvar_histogram_unittest.cpp | 167 +++++++++++++++++++++++++++++++++++++++
6 files changed, 353 insertions(+), 32 deletions(-)
diff --git a/BUILD.bazel b/BUILD.bazel
index db73605e..23e433c7 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -424,7 +424,10 @@ cc_library(
deps = [
":butil",
] + select({
- "//bazel/config:with_babylon_counter":
["@babylon//:concurrent_counter"],
+ "//bazel/config:with_babylon_counter": [
+ "@babylon//:concurrent_counter",
+ "@babylon//:concurrent_thread_local",
+ ],
"//conditions:default": [],
}),
)
diff --git a/src/bvar/detail/sampler.h b/src/bvar/detail/sampler.h
index 06533e4e..d120458d 100644
--- a/src/bvar/detail/sampler.h
+++ b/src/bvar/detail/sampler.h
@@ -198,7 +198,7 @@ public:
// would not be ignored
take_sample();
}
- ~ReducerSampler() {}
+ ~ReducerSampler() override = default;
void take_sample() override {
// Make _q ready.
@@ -222,21 +222,7 @@ public:
}
Sample<T> latest;
- if (butil::is_same<InvOp, VoidOp>::value) {
- // The operator can't be inversed.
- // We reset the reducer and save the result as a sample.
- // Suming up samples gives the result within a window.
- // In this case, get_value() of _reducer gives wrong answer and
- // should not be called.
- latest.data = _source.reset();
- } else {
- // The operator can be inversed.
- // We save the result as a sample.
- // Inversed operation between latest and oldest sample within a
- // window gives result.
- // get_value() of _reducer can still be called.
- latest.data = _source.get_value();
- }
+ latest.data = take_sample_of(butil::is_same<InvOp, VoidOp>());
latest.time_us = butil::cpuwide_time_us();
_q.elim_push(latest);
}
@@ -313,6 +299,23 @@ public:
}
private:
+ // Tag dispatch instead of a runtime branch on is_same<InvOp, VoidOp>, so
+ // that only the taken branch is instantiated.
+
+ // The operator can't be inversed.
+ // We reset the reducer and save the result as a sample.
+ // Summing up samples gives the result within a window.
+ // In this case, get_value() of `_source` gives wrong answer and
+ // should not be called.
+ T take_sample_of(butil::true_type) { return _source.reset(); }
+
+ // The operator can be inversed.
+ // We save the result as a sample.
+ // Inversed operation between latest and oldest sample within a
+ // window gives result.
+ // get_value() of `_source` can still be called.
+ T take_sample_of(butil::false_type) { return _source.get_value(); }
+
source_type _source;
time_t _window_size;
butil::BoundedQueue<Sample<T> > _q;
diff --git a/src/bvar/histogram.cpp b/src/bvar/histogram.cpp
index 2288bd24..af4fcb69 100644
--- a/src/bvar/histogram.cpp
+++ b/src/bvar/histogram.cpp
@@ -100,10 +100,14 @@ std::ostream& operator<<(std::ostream& os, const
Histogram::Value& v) {
Histogram::Histogram(const BucketSchema& schema)
: _schema(schema)
+#if WITH_BABYLON_COUNTER
+ ,
_storage(std::make_shared<detail::HistogramStorage>(schema.num_buckets()))
+#else
// Both identities carry `num_buckets` so that a value combined out of no
// agent at all still knows how wide it is.
,
_combiner(std::make_shared<combiner_type>(value_type(schema.num_buckets()),
value_type(schema.num_buckets())))
+#endif // WITH_BABYLON_COUNTER
, _sampler(nullptr) {
}
@@ -133,6 +137,9 @@ Histogram& Histogram::operator<<(double value) {
<< " recorded into Histogram(" << name() <<
')';
return *this;
}
+#if WITH_BABYLON_COUNTER
+ _storage->add(_schema.index_of(value), value);
+#else
agent_type* agent = _combiner->get_or_create_tls_agent();
if (BAIDU_UNLIKELY(agent == nullptr)) {
LOG(FATAL) << "Fail to create agent";
@@ -140,9 +147,18 @@ Histogram& Histogram::operator<<(double value) {
}
// `_schema` outlives the call, the op only borrows it to find the bucket.
agent->element.modify(detail::AddSampleToHistogram(&_schema), value);
+#endif // WITH_BABYLON_COUNTER
return *this;
}
+Histogram::value_type Histogram::get_value() const {
+#if WITH_BABYLON_COUNTER
+ return _storage->combine_agents();
+#else
+ return _combiner->combine_agents();
+#endif // WITH_BABYLON_COUNTER
+}
+
Histogram::sampler_type* Histogram::get_sampler() {
if (_sampler == nullptr) {
_sampler = new sampler_type(this);
diff --git a/src/bvar/histogram.h b/src/bvar/histogram.h
index d2623841..468805f4 100644
--- a/src/bvar/histogram.h
+++ b/src/bvar/histogram.h
@@ -19,15 +19,22 @@
#define BVAR_HISTOGRAM_H
#include <stdint.h> // int64_t, uint64_t
-#include <algorithm> // std::lower_bound
+#include <algorithm> // std::max
#include <initializer_list> // std::initializer_list
+#include <memory> // std::shared_ptr
#include <string> // std::string
#include <vector> // std::vector
#include "butil/strings/string_piece.h" // butil::StringPiece
#include "bvar/variable.h" // Variable
-#include "bvar/detail/combiner.h" // AgentCombiner
#include "bvar/detail/sampler.h" // ReducerSampler
#include "bvar/detail/series.h" // HasPlottableSeries
+#if WITH_BABYLON_COUNTER
+#include "babylon/concurrent/thread_local.h" // EnumerableThreadLocal
+#include "butil/atomicops.h" // butil::atomic
+#include "butil/synchronization/seqlock.h" // butil::Seqlock
+#else
+#include "bvar/detail/combiner.h" // AgentCombiner
+#endif // WITH_BABYLON_COUNTER
namespace bvar {
@@ -36,6 +43,13 @@ namespace bvar {
// bounds.
static const size_t MAX_HISTOGRAM_BUCKETS = 32;
+#if WITH_BABYLON_COUNTER
+namespace detail {
+// Defined below, once Histogram::Value is complete.
+class HistogramStorage;
+} // namespace detail
+#endif // WITH_BABYLON_COUNTER
+
// Bucketed distribution of the recorded values.
//
// Each bucket count accumulates since construction and never decreases. The
@@ -79,9 +93,17 @@ public:
BucketSchema(std::initializer_list<double> bounds);
explicit BucketSchema(const std::vector<double>& bounds);
+ // Linear rather than a std::lower_bound: a schema holds at most
+ // MAX_HISTOGRAM_BUCKETS - 1 bounds, which is a couple of cache lines
+ // scanned straight through instead of jumped around in, and the
+ // branch of the scan predicts far better than the one of a binary
+ // search, whose direction is a coin flip at every step.
size_t index_of(double value) const {
- return std::lower_bound(_bounds.begin(), _bounds.end(), value) -
- _bounds.begin();
+ size_t index = 0;
+ while (index < _bounds.size() && _bounds[index] < value) {
+ ++index;
+ }
+ return index;
}
size_t num_buckets() const { return _bounds.size() + 1; }
@@ -148,9 +170,13 @@ public:
typedef Value value_type;
typedef detail::ReducerSampler<Histogram, value_type, Op, InvOp>
sampler_type;
+#if WITH_BABYLON_COUNTER
+ typedef std::shared_ptr<detail::HistogramStorage> shared_combiner_type;
+#else
typedef detail::AgentCombiner<value_type, value_type, Op> combiner_type;
typedef combiner_type::self_shared_type shared_combiner_type;
typedef combiner_type::Agent agent_type;
+#endif // WITH_BABYLON_COUNTER
explicit Histogram(const BucketSchema& schema);
Histogram(const butil::StringPiece& name, const BucketSchema& schema);
@@ -168,7 +194,11 @@ public:
const BucketSchema& schema() const { return _schema; }
bool valid() const {
+#if WITH_BABYLON_COUNTER
+ return _storage != nullptr;
+#else
return _combiner != nullptr && _combiner->valid();
+#endif // WITH_BABYLON_COUNTER
}
void describe(std::ostream& os, bool quote_string) const override;
@@ -189,10 +219,16 @@ public:
// The contract of Window<>/ReducerSampler
Op op() const { return Op(); }
InvOp inv_op() const { return InvOp(); }
- // Expose the shared data carrier, so that ReducerSampler holds it instead
- // of `this`. Sampling then keeps reading valid memory even if this
- // Percentile is destructed before the sampler is recycled.
- shared_combiner_type share_combiner() const { return _combiner; }
+ // Expose the shared data carrier, so that ReducerSampler holds it
+ // instead of `this`. Sampling then keeps reading valid memory even
+ // if this Histogram is destructed before the sampler is recycled.
+ shared_combiner_type share_combiner() const {
+#if WITH_BABYLON_COUNTER
+ return _storage;
+#else
+ return _combiner;
+#endif // WITH_BABYLON_COUNTER
+ }
sampler_type* get_sampler();
private:
@@ -202,10 +238,14 @@ private:
// Snapshot of all the values recorded so far. Walks through every thread
// that ever recorded into this Histogram.
- value_type get_value() const { return _combiner->combine_agents(); }
+ value_type get_value() const;
BucketSchema _schema;
+#if WITH_BABYLON_COUNTER
+ shared_combiner_type _storage;
+#else
shared_combiner_type _combiner;
+#endif // WITH_BABYLON_COUNTER
sampler_type* _sampler;
};
@@ -219,6 +259,101 @@ namespace detail {
template <>
struct HasPlottableSeries<Histogram::Value> : butil::false_type {};
+#if WITH_BABYLON_COUNTER
+
+// One thread's slice of a Histogram.
+//
+// Only the thread owning the slot writes it, so the counters are updated with
+// a relaxed load plus a relaxed store rather than an atomic read-modify-write.
+// They are atomic all the same because the sampling thread reads them while
+// they are being written, which the seqlock allows but does not by itself make
+// race free. The seqlock is what keeps the buckets, the sum and the count of
one
+// slot mutually consistent.
+class HistogramSlot {
+public:
+ HistogramSlot() {
+ for (size_t i = 0; i < MAX_HISTOGRAM_BUCKETS; ++i) {
+ _counts[i].store(0, butil::memory_order_relaxed);
+ }
+ }
+
+ DISALLOW_COPY_AND_ASSIGN(HistogramSlot);
+
+ void add(size_t bucket_index, double value) {
+ _seqlock.store([&] {
+ relaxed_add(&_counts[bucket_index], (uint64_t)1);
+ relaxed_add(&_sum, value);
+ relaxed_add(&_num, (int64_t)1);
+ });
+ }
+
+ Histogram::Value load(size_t num_buckets) const {
+ return _seqlock.load([&] {
+ Histogram::Value v(num_buckets);
+ for (size_t i = 0; i < num_buckets; ++i) {
+ v.counts[i] = _counts[i].load(butil::memory_order_relaxed);
+ }
+ v.sum = _sum.load(butil::memory_order_relaxed);
+ v.num = _num.load(butil::memory_order_relaxed);
+ return v;
+ });
+ }
+
+private:
+ template <typename T, typename U>
+ static void relaxed_add(butil::atomic<T>* target, U delta) {
+ target->store(target->load(butil::memory_order_relaxed) + delta,
+ butil::memory_order_relaxed);
+ }
+
+ butil::Seqlock<> _seqlock;
+ butil::atomic<uint64_t> _counts[MAX_HISTOGRAM_BUCKETS];
+ butil::atomic<double> _sum{0.0};
+ butil::atomic<int64_t> _num{0};
+};
+
+// The per thread slices of one Histogram and their aggregation.
+//
+// babylon's EnumerableThreadLocal hands out one slot per thread and walks
every
+// slot any thread ever took, which is how a thread that has exited keeps
+// contributing what it recorded, the way AgentCombiner commits a dying agent
+// into its global result. babylon recycles the thread id of an exited thread,
+// so a later thread inherits the slot and accumulates on top of it: correct
+// here because a Histogram only ever adds to its buckets and never clears one.
+class HistogramStorage {
+public:
+ explicit HistogramStorage(size_t num_buckets) : _num_buckets(num_buckets)
{}
+
+ DISALLOW_COPY_AND_ASSIGN(HistogramStorage);
+
+ // Records one value into the slot of the calling thread.
+ void add(size_t bucket_index, double value) {
+ _slots.local().add(bucket_index, value);
+ }
+
+ // [Threadsafe] Everything recorded so far by every thread that ever
+ // recorded into this Histogram. Named after
AgentCombiner::combine_agents()
+ // so that detail::CombinerSampleSource fits either backend.
+ Histogram::Value combine_agents() const {
+ Histogram::Value result(_num_buckets);
+ _slots.for_each([&](const HistogramSlot* iter, const HistogramSlot*
end) {
+ for (; iter != end; ++iter) {
+ result += iter->load(_num_buckets);
+ }
+ });
+ return result;
+ }
+
+private:
+ // Leaky: the id allocator behind the thread ids is never destroyed, which
+ // is what a Histogram of static storage duration needs. The thread ids
+ // themselves are still recycled when a thread exits.
+ babylon::EnumerableThreadLocal<HistogramSlot, true> _slots;
+ size_t _num_buckets;
+};
+
+#else
+
// The op of the writing path takes a recorded value rather than another
// Histogram::Value, and needs the schema to find its bucket.
struct AddSampleToHistogram {
@@ -231,6 +366,8 @@ struct AddSampleToHistogram {
const Histogram::BucketSchema* schema;
};
+#endif // WITH_BABYLON_COUNTER
+
} // namespace detail
} // namespace bvar
diff --git a/src/bvar/passive_status.h b/src/bvar/passive_status.h
index 7122e741..c6252c71 100644
--- a/src/bvar/passive_status.h
+++ b/src/bvar/passive_status.h
@@ -102,7 +102,7 @@ public:
, _series_sampler(nullptr) {
}
- ~PassiveStatus() {
+ ~PassiveStatus() override {
hide();
if (_sampler) {
_sampler->destroy();
@@ -162,11 +162,6 @@ public:
return 0;
}
- Tp reset() {
- CHECK(false) << "PassiveStatus::reset() should never be called, abort";
- abort();
- }
-
protected:
int expose_impl(const butil::StringPiece& prefix,
const butil::StringPiece& n,
diff --git a/test/bvar_histogram_unittest.cpp b/test/bvar_histogram_unittest.cpp
index 80b53c75..f5a83c5d 100644
--- a/test/bvar_histogram_unittest.cpp
+++ b/test/bvar_histogram_unittest.cpp
@@ -21,6 +21,7 @@
#include <stdio.h> // snprintf
#include <string.h> // memset
#include <algorithm> // std::max
+#include <iomanip> // std::setprecision
#include <limits>
#include <map>
#include <memory> // std::make_shared
@@ -30,6 +31,7 @@
#include <gtest/gtest.h>
#include <butil/atomicops.h>
#include <butil/float_util.h>
+#include <butil/logging.h>
#include <butil/strings/string_number_conversions.h>
#include <butil/time.h>
#include "bvar/bvar.h"
@@ -44,6 +46,54 @@ namespace {
// below call private members such as Histogram::get_value() directly.
class HistogramTest : public testing::Test {};
+#if WITH_BABYLON_COUNTER
+
+// One thread's slice of a Histogram, the babylon backed replacement of the
+// ElementContainer tested below.
+TEST_F(HistogramTest, histogram_slot) {
+ bvar::Histogram::BucketSchema schema({10, 20, 30});
+ bvar::detail::HistogramSlot slot;
+ // Freshly constructed, before any add().
+ bvar::Histogram::Value v = slot.load(schema.num_buckets());
+ ASSERT_EQ(0, v.num);
+ ASSERT_DOUBLE_EQ(0.0, v.sum);
+ ASSERT_EQ(0u, v.counts[0]);
+
+ slot.add(schema.index_of(5.25), 5.25);
+ slot.add(schema.index_of(25.5), 25.5);
+ slot.add(schema.index_of(1000.75), 1000.75);
+ v = slot.load(schema.num_buckets());
+ ASSERT_EQ(3, v.num);
+ ASSERT_DOUBLE_EQ(1031.5, v.sum);
+ ASSERT_EQ(4u, v.num_buckets);
+ ASSERT_EQ(1u, v.counts[0]); // 5.25 -> (-inf, 10]
+ ASSERT_EQ(0u, v.counts[1]);
+ ASSERT_EQ(1u, v.counts[2]); // 25.5 -> (20, 30]
+ ASSERT_EQ(1u, v.counts[3]); // 1000.75 -> +Inf
+}
+
+// A slot is taken lazily, on the first record of a thread, and the storage
+// aggregates every slot ever taken.
+TEST_F(HistogramTest, histogram_storage) {
+ bvar::Histogram::BucketSchema schema({10, 20, 30});
+ bvar::detail::HistogramStorage storage(schema.num_buckets());
+ // No thread has recorded anything, yet the combined value already knows
+ // how wide the histogram is.
+ bvar::Histogram::Value v = storage.combine_agents();
+ ASSERT_EQ(0, v.num);
+ ASSERT_EQ(4u, v.num_buckets);
+
+ storage.add(schema.index_of(5.25), 5.25);
+ storage.add(schema.index_of(25.5), 25.5);
+ v = storage.combine_agents();
+ ASSERT_EQ(2, v.num);
+ ASSERT_DOUBLE_EQ(30.75, v.sum);
+ ASSERT_EQ(1u, v.counts[0]);
+ ASSERT_EQ(1u, v.counts[2]);
+}
+
+#else
+
// The element container of a Histogram::Value, which is the generic mutex one:
// the value is far too wide to be atomical.
TEST_F(HistogramTest, element_container) {
@@ -76,6 +126,8 @@ TEST_F(HistogramTest, element_container) {
ASSERT_EQ(0u, v.counts[3]);
}
+#endif // WITH_BABYLON_COUNTER
+
// Fixed workload for export and performance tests, not a library default.
static bvar::Histogram::BucketSchema test_latency_schema() {
return {10, 20, 40, 80, 160, 320, 640, 1280, 2560, 5120,
@@ -724,4 +776,119 @@ TEST_F(HistogramTest, multithreaded) {
}
ASSERT_DOUBLE_EQ((double)expected_sum * nrecords / 40, v.sum);
}
+
+// What a thread recorded outlives it: the AgentCombiner backend commits a
+// dying agent into its global result, the babylon one keeps the slot of an
+// exited thread around for whichever thread inherits its id later.
+TEST_F(HistogramTest, values_of_dead_threads_are_kept) {
+ int64_t nvalues = 100;
+ bvar::Histogram h(bvar::Histogram::BucketSchema({10, 20, 30}));
+ AddArgs args = {&h, nvalues};
+ for (int64_t round = 1; round <= 3; ++round) {
+ pthread_t th;
+ ASSERT_EQ(0, pthread_create(&th, nullptr, add_values, &args));
+ ASSERT_EQ(0, pthread_join(th, nullptr));
+ // Every round runs on a thread which is gone by the time count() reads
+ // what it recorded, and the next one may well reuse its slot.
+ ASSERT_EQ(nvalues * round, h.count());
+ }
+}
+
+static void check_snapshots_while_recording(bvar::Histogram* h,
+ int64_t nrecords) {
+ bvar::Histogram::Value v = h->get_value();
+ std::vector<uint64_t> last_counts(v.num_buckets, 0);
+ for (int i = 0; i < 1000 || v.num < nrecords; ++i) {
+ v = h->get_value();
+ uint64_t total = 0;
+ for (size_t b = 0; b < last_counts.size(); ++b) {
+ total += v.counts[b];
+ // Every sample is taken after the previous one returned and a
+ // bucket count only ever grows, so this snapshot cannot hold less
+ // than the last one did.
+ ASSERT_LE(last_counts[b], v.counts[b]) << "i=" << i << " bucket="
<< b;
+ last_counts[b] = v.counts[b];
+ }
+ ASSERT_EQ(v.num, (int64_t)total) << "i=" << i;
+ }
+}
+
+// The invariant the per thread seqlock buys, the mutex of the ElementContainer
+// without WITH_BABYLON_COUNTER: a snapshot never mixes a bucket that has
+// already been incremented with a `num` that has not. It holds for the slice
+// of one thread, and summing consistent slices keeps it, so the whole snapshot
+// still satisfies the `+Inf bucket == _count` rule of the prometheus format
+// while other threads are recording.
+TEST_F(HistogramTest, snapshot_is_self_consistent_under_contention) {
+ int64_t nvalues = 50000;
+ bvar::Histogram h(bvar::Histogram::BucketSchema({10, 20, 30}));
+
+ pthread_t threads[4];
+ AddArgs args = {&h, nvalues};
+ for (size_t i = 0; i < arraysize(threads); ++i) {
+ ASSERT_EQ(0, pthread_create(&threads[i], nullptr, add_values, &args));
+ }
+
+ int64_t nrecords = (int64_t)arraysize(threads) * nvalues;
+ check_snapshots_while_recording(&h, nrecords);
+
+ for (size_t i = 0; i < arraysize(threads); ++i) {
+ ASSERT_EQ(0, pthread_join(threads[i], nullptr));
+ }
+ ASSERT_EQ(nrecords, h.count());
+}
+
+static const size_t PERF_OPS_PER_THREAD = 500000;
+
+struct PerfArgs {
+ bvar::Histogram* h;
+ int64_t elapsed_ns;
+};
+
+static void* record_into_histogram(void* arg) {
+ PerfArgs* args = (PerfArgs*)arg;
+ butil::Timer timer;
+ timer.start();
+ for (size_t i = 0; i < PERF_OPS_PER_THREAD; ++i) {
+ *args->h << (double)(i % 40);
+ }
+ timer.stop();
+ args->elapsed_ns = timer.n_elapsed();
+ return nullptr;
+}
+
+static double time_records(bvar::Histogram* h, size_t nthread) {
+ PerfArgs proto = {h, 0};
+ std::vector<PerfArgs> args(nthread, proto);
+ std::vector<pthread_t> threads(nthread);
+ for (size_t i = 0; i < nthread; ++i) {
+ EXPECT_EQ(0, pthread_create(&threads[i], nullptr,
+ record_into_histogram, &args[i]));
+ }
+ int64_t total_ns = 0;
+ for (size_t i = 0; i < nthread; ++i) {
+ EXPECT_EQ(0, pthread_join(threads[i], nullptr));
+ total_ns += args[i].elapsed_ns;
+ }
+ return (double)total_ns / (double)(PERF_OPS_PER_THREAD * nthread);
+}
+
+TEST_F(HistogramTest, write_perf) {
+#if WITH_BABYLON_COUNTER
+ const char* backend = "babylon";
+#else
+ const char* backend = "combiner";
+#endif // WITH_BABYLON_COUNTER
+ std::ostringstream oss;
+ oss << std::fixed << std::setprecision(2)
+ << "threads\t" << backend << " (ns per record)\n";
+ for (size_t nthread = 1; nthread <= 8; nthread *= 2) {
+ bvar::Histogram h(bvar::Histogram::BucketSchema({10, 20, 30}));
+ double ns = time_records(&h, nthread);
+ ASSERT_EQ((int64_t)(PERF_OPS_PER_THREAD * nthread), h.count());
+ oss << nthread << '\t' << ns << '\n';
+ }
+ LOG(INFO) << "Histogram write performance:\n" << oss.str();
+}
+
} // namespace
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]