Skip to content

Commit 04f3bc0

Browse files
toddlipconcopybara-github
authored andcommitted
Fix hashtablez oversampling for SOO tables on repeated 0->1 element transitions
Currently, when an absl::flat_hash_set or flat_hash_map with SOO (Single Object Optimization) transitions from 0 to 1 element, it queries should_sample_soo() to determine if it should be sampled by hashtablez. If it is NOT sampled, it remains an SOO table. If that element is subsequently erased, the table goes back to 0 elements. The next time it goes from 0 to 1, it queries should_sample_soo() again. Repeated insert/erase cycles on an SOO table effectively evaluate the sampling logic hundreds of times, making it extremely likely that the table eventually gets sampled and moved to the heap. This causes hashtablez to vastly overstate the memory usage of such maps in production. This CL fixes this by tracking whether an SOO table has already been evaluated for sampling, so it only evaluates the logic once per table object instance. Since SOO tables do not hash keys, they do not need the per-table generic seed value. This CL utilizes the lowest bit of the unused seed field within HashtableSize (accessed via data_ & 1) to act as a flag soo_has_tried_sampling. When an SOO table transitions from 0 elements to 1 element, this bit is checked. If it is false, should_sample_soo() is queried and the bit is set to true. set_full_soo() and set_empty_soo() have been modified to preserve the soo_has_tried_sampling bit across erase or clear operations. PiperOrigin-RevId: 881570273 Change-Id: Ia3b0eff8b3e137b0d162a207220a184923e3a2ca
1 parent e7ba8a7 commit 04f3bc0

2 files changed

Lines changed: 170 additions & 13 deletions

File tree

absl/container/internal/raw_hash_set.h

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -459,26 +459,41 @@ class PerTableSeed {
459459

460460
// The size and also has additionally
461461
// 1) one bit that stores whether we have infoz.
462-
// 2) PerTableSeed::kBitCount bits for the seed.
462+
// 2) PerTableSeed::kBitCount bits for the seed. (For SOO tables, the lowest
463+
// bit of the seed is repurposed to track if sampling has been tried).
463464
class HashtableSize {
464465
public:
465466
static constexpr size_t kSizeBitCount = 64 - PerTableSeed::kBitCount - 1;
466467

467468
explicit HashtableSize(uninitialized_tag_t) {}
468469
explicit HashtableSize(no_seed_empty_tag_t) : data_(0) {}
469-
explicit HashtableSize(full_soo_tag_t) : data_(kSizeOneNoMetadata) {}
470+
HashtableSize(full_soo_tag_t, bool has_tried_sampling)
471+
: data_(kSizeOneNoMetadata |
472+
(has_tried_sampling ? kSooHasTriedSamplingMask : 0)) {}
470473

471474
// Returns actual size of the table.
472475
size_t size() const { return static_cast<size_t>(data_ >> kSizeShift); }
473476
void increment_size() { data_ += kSizeOneNoMetadata; }
474477
void increment_size(size_t size) {
475-
data_ += static_cast<uint64_t>(size) * kSizeOneNoMetadata;
478+
data_ += static_cast<uint64_t>(size) << kSizeShift;
476479
}
477480
void decrement_size() { data_ -= kSizeOneNoMetadata; }
478481
// Returns true if the table is empty.
479482
bool empty() const { return data_ < kSizeOneNoMetadata; }
480-
// Sets the size to zero, but keeps all the metadata bits.
481-
void set_size_to_zero_keep_metadata() { data_ = data_ & kMetadataMask; }
483+
484+
// Returns true if an empty SOO table has already queried should_sample_soo().
485+
bool soo_has_tried_sampling() const {
486+
return (data_ & kSooHasTriedSamplingMask) != 0;
487+
}
488+
489+
// Records that an empty SOO table has tried sampling.
490+
void set_soo_has_tried_sampling() { data_ |= kSooHasTriedSamplingMask; }
491+
492+
// Sets the size, but keeps all the metadata bits.
493+
void set_size_keep_metadata(size_t size) {
494+
data_ =
495+
(data_ & kMetadataMask) | (static_cast<uint64_t>(size) << kSizeShift);
496+
}
482497

483498
PerTableSeed seed() const {
484499
return PerTableSeed(static_cast<size_t>(data_) & kSeedMask);
@@ -509,13 +524,20 @@ class HashtableSize {
509524

510525
private:
511526
void set_seed(uint16_t seed) { data_ = (data_ & ~kSeedMask) | seed; }
527+
// Bit layout of `data_`:
528+
// [63 ... 17] (47 bits) : size
529+
// [16] (1 bit) : has_infoz
530+
// [15 ... 0] (16 bits) : seed
512531
static constexpr size_t kSizeShift = 64 - kSizeBitCount;
513532
static constexpr uint64_t kSizeOneNoMetadata = uint64_t{1} << kSizeShift;
514533
static constexpr uint64_t kMetadataMask = kSizeOneNoMetadata - 1;
515534
static constexpr uint64_t kSeedMask =
516535
(uint64_t{1} << PerTableSeed::kBitCount) - 1;
517536
// The next bit after the seed.
518537
static constexpr uint64_t kHasInfozMask = kSeedMask + 1;
538+
// For SOO tables, the seed is unused, and bit 0 is repurposed to track
539+
// whether the table has already queried should_sample_soo().
540+
static constexpr uint64_t kSooHasTriedSamplingMask = 1;
519541
uint64_t data_;
520542
};
521543

@@ -907,8 +929,8 @@ class CommonFields : public CommonFieldsGenerationInfo {
907929
public:
908930
explicit CommonFields(soo_tag_t)
909931
: capacity_(SooCapacity()), size_(no_seed_empty_tag_t{}) {}
910-
explicit CommonFields(full_soo_tag_t)
911-
: capacity_(SooCapacity()), size_(full_soo_tag_t{}) {}
932+
explicit CommonFields(full_soo_tag_t, bool has_tried_sampling)
933+
: capacity_(SooCapacity()), size_(full_soo_tag_t{}, has_tried_sampling) {}
912934
explicit CommonFields(non_soo_tag_t)
913935
: capacity_(0), size_(no_seed_empty_tag_t{}) {}
914936
// For use in swapping.
@@ -963,14 +985,14 @@ class CommonFields : public CommonFieldsGenerationInfo {
963985
// The number of filled slots.
964986
size_t size() const { return size_.size(); }
965987
// Sets the size to zero, but keeps hashinfoz bit and seed.
966-
void set_size_to_zero() { size_.set_size_to_zero_keep_metadata(); }
988+
void set_size_to_zero() { size_.set_size_keep_metadata(0); }
967989
void set_empty_soo() {
968990
AssertInSooMode();
969-
size_ = HashtableSize(no_seed_empty_tag_t{});
991+
size_.set_size_keep_metadata(0);
970992
}
971993
void set_full_soo() {
972994
AssertInSooMode();
973-
size_ = HashtableSize(full_soo_tag_t{});
995+
size_.set_size_keep_metadata(1);
974996
}
975997
void increment_size() {
976998
ABSL_SWISSTABLE_ASSERT(size() < capacity());
@@ -985,6 +1007,8 @@ class CommonFields : public CommonFieldsGenerationInfo {
9851007
size_.decrement_size();
9861008
}
9871009
bool empty() const { return size_.empty(); }
1010+
void set_soo_has_tried_sampling() { size_.set_soo_has_tried_sampling(); }
1011+
bool soo_has_tried_sampling() const { return size_.soo_has_tried_sampling(); }
9881012

9891013
// The seed used for the hash function.
9901014
PerTableSeed seed() const { return size_.seed(); }
@@ -2271,7 +2295,8 @@ class raw_hash_set {
22712295
// Note: we avoid using exchange for better generated code.
22722296
settings_(PolicyTraits::transfer_uses_memcpy() || !that.is_full_soo()
22732297
? std::move(that.common())
2274-
: CommonFields{full_soo_tag_t{}},
2298+
: CommonFields{full_soo_tag_t{},
2299+
that.common().soo_has_tried_sampling()},
22752300
that.hash_ref(), that.eq_ref(), that.char_alloc_ref()) {
22762301
if (!PolicyTraits::transfer_uses_memcpy() && that.is_full_soo()) {
22772302
transfer(soo_slot(), that.soo_slot());
@@ -3011,12 +3036,23 @@ class raw_hash_set {
30113036
}
30123037
}
30133038

3014-
// Returns true if the table needs to be sampled.
3039+
// Returns true if the table needs to be sampled. This keeps track of whether
3040+
// sampling has already been evaluated and ensures that it can only return
3041+
// true on its first evaluation. All subsequent calls will return false.
3042+
//
30153043
// This should be called on insertion into an empty SOO table and in copy
30163044
// construction when the size can fit in SOO capacity.
3017-
bool should_sample_soo() const {
3045+
bool should_sample_soo() {
30183046
ABSL_SWISSTABLE_ASSERT(is_soo());
30193047
if (!ShouldSampleHashtablezInfoForAlloc<CharAlloc>()) return false;
3048+
if (common().soo_has_tried_sampling()) {
3049+
// Already evaluated sampling on this SOO table; do not re-evaluate
3050+
// sampling each time it transitions from empty to full SOO state.
3051+
return false;
3052+
}
3053+
// TODO: b/396049910 -- consider managing this flag on the 1->0 size
3054+
// transition of SOO tables rather than the 0->1 transition.
3055+
common().set_soo_has_tried_sampling();
30203056
return ABSL_PREDICT_FALSE(ShouldSampleNextTable());
30213057
}
30223058

absl/container/internal/raw_hash_set_test.cc

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2932,6 +2932,70 @@ TEST(RawHashSamplerTest, SooTableInsertToEmpty) {
29322932
}
29332933
}
29342934

2935+
// Verifies that repeated insertions and erasures on an SOO table do not cause
2936+
// the sampling decision to be evaluated multiple times, preventing
2937+
// oversampling.
2938+
TEST(RawHashSamplerTest, SooTableRepeatedInsertEraseDoesNotOversample) {
2939+
if (SooInt32Table().capacity() != SooCapacity()) {
2940+
CHECK_LT(sizeof(void*), 8) << "missing SOO coverage";
2941+
GTEST_SKIP() << "not SOO on this platform";
2942+
}
2943+
std::vector<const HashtablezInfo*> infos =
2944+
SampleSooMutation([](SooInt32Table& t) {
2945+
for (int i = 0; i < 10; ++i) {
2946+
t.insert(1);
2947+
t.erase(1);
2948+
}
2949+
});
2950+
2951+
// SampleSooMutation checks EXPECT_NEAR(sampled/total, 1%, 0.5%).
2952+
// If the sampling logic is incorrectly evaluated on every 0->1 element
2953+
// transition, the chance of being sampled per table approaches 10% (1 -
2954+
// 0.99^10), which is enough to cause this test to fail. By passing, this test
2955+
// verifies that the sampling decision is evaluated exactly once per SOO table
2956+
// instance.
2957+
}
2958+
2959+
// Verifies that copy-constructing or copy-assigning an SOO table does not
2960+
// incorrectly trigger new sampling evaluations.
2961+
TEST(RawHashSamplerTest, SooTableCopyDoesNotOversample) {
2962+
if (SooInt32Table().capacity() != SooCapacity()) {
2963+
CHECK_LT(sizeof(void*), 8) << "missing SOO coverage";
2964+
GTEST_SKIP() << "not SOO on this platform";
2965+
}
2966+
std::vector<const HashtablezInfo*> infos =
2967+
SampleSooMutation([](SooInt32Table& t) {
2968+
t.insert(1);
2969+
t.erase(1);
2970+
SooInt32Table t_copy(t);
2971+
for (int i = 0; i < 10; ++i) {
2972+
t_copy.insert(1);
2973+
t_copy.erase(1);
2974+
}
2975+
t = std::move(t_copy);
2976+
});
2977+
}
2978+
2979+
// Verifies that move-constructing or move-assigning an SOO table correctly
2980+
// transfers the sampling state and does not trigger oversampling.
2981+
TEST(RawHashSamplerTest, SooTableMoveDoesNotOversample) {
2982+
if (SooInt32Table().capacity() != SooCapacity()) {
2983+
CHECK_LT(sizeof(void*), 8) << "missing SOO coverage";
2984+
GTEST_SKIP() << "not SOO on this platform";
2985+
}
2986+
std::vector<const HashtablezInfo*> infos =
2987+
SampleSooMutation([](SooInt32Table& t) {
2988+
t.insert(1);
2989+
t.erase(1);
2990+
SooInt32Table t_moved(std::move(t));
2991+
for (int i = 0; i < 10; ++i) {
2992+
t_moved.insert(1);
2993+
t_moved.erase(1);
2994+
}
2995+
t = std::move(t_moved);
2996+
});
2997+
}
2998+
29352999
TEST(RawHashSamplerTest, SooTableReserveToEmpty) {
29363000
if (SooInt32Table().capacity() != SooCapacity()) {
29373001
CHECK_LT(sizeof(void*), 8) << "missing SOO coverage";
@@ -3029,6 +3093,63 @@ TEST(RawHashSamplerTest, SooTableRehashShrinkWhenSizeFitsInSoo) {
30293093
ASSERT_EQ(info->total_probe_length, 0);
30303094
}
30313095
}
3096+
3097+
// Verifies that a moved-from table does not retain the sampled state of the
3098+
// original table, allowing it to be used without incorrectly updating global
3099+
// sampling stats.
3100+
TEST(RawHashSamplerTest, MovedFromTableIsNotSampled) {
3101+
if (SooInt32Table().capacity() != SooCapacity()) {
3102+
CHECK_LT(sizeof(void*), 8) << "missing SOO coverage";
3103+
GTEST_SKIP() << "not SOO on this platform";
3104+
}
3105+
3106+
SetSamplingRateTo1Percent();
3107+
auto& sampler = GlobalHashtablezSampler();
3108+
size_t start_size = 0;
3109+
absl::flat_hash_set<const HashtablezInfo*> preexisting_info;
3110+
sampler.Iterate([&](const HashtablezInfo& info) {
3111+
preexisting_info.insert(&info);
3112+
++start_size;
3113+
});
3114+
3115+
SooInt32Table t1;
3116+
// Loop until t1 is sampled
3117+
while (true) {
3118+
t1 = SooInt32Table();
3119+
t1.insert(1);
3120+
size_t new_size = 0;
3121+
sampler.Iterate([&](const HashtablezInfo&) { ++new_size; });
3122+
if (new_size > start_size) break;
3123+
}
3124+
3125+
// Move the table
3126+
SooInt32Table t2 = std::move(t1);
3127+
3128+
// Disable sampling to ensure any new sampling is a bug.
3129+
SetHashtablezEnabled(false);
3130+
3131+
// t2 is now the sampled table. t1 is moved-from.
3132+
// We want to verify that t2 is still sampled, and that t1 isn't sampled
3133+
// anymore, even if we insert a new entry into it.
3134+
t1.clear(); // Must clear before using a moved-from table.
3135+
t1.insert(2);
3136+
t2.insert(2);
3137+
3138+
// Verify no new sample was generated, and t2's sample size is now 2.
3139+
size_t final_size = 0;
3140+
const HashtablezInfo* latest_info = nullptr;
3141+
size_t dropped = sampler.Iterate([&](const HashtablezInfo& info) {
3142+
++final_size;
3143+
if (!preexisting_info.contains(&info)) {
3144+
latest_info = &info;
3145+
}
3146+
});
3147+
EXPECT_EQ(0, dropped);
3148+
EXPECT_EQ(final_size, start_size + 1);
3149+
ASSERT_NE(latest_info, nullptr);
3150+
EXPECT_EQ(latest_info->size.load(std::memory_order_relaxed), 2);
3151+
}
3152+
30323153
#endif // ABSL_INTERNAL_HASHTABLEZ_SAMPLE
30333154

30343155
TEST(RawHashSamplerTest, DoNotSampleCustomAllocators) {

0 commit comments

Comments
 (0)