TCMalloc 采样机制深度解析:从分配采样到堆/碎片/生命周期画像
2026/9/17 14:24:54 网站建设 项目流程

TCMalloc 采样机制深度解析:从分配采样到堆/碎片/生命周期画像

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

导读

本文以 MongoDB 仓库内置的 TCMalloc 源码(src/third_party/tcmalloc/dist/)为核心,系统讲解 TCMalloc 的采样(sampling)机制:如何用“每 N 字节采样一次”的统计方法低成本获得内存使用与分配的代表性数据,如何对采样分配进行加权修正,以及如何基于采样结果构建堆画像(heap profile)、碎片画像(fragmentation profile)、分配画像(allocation profile)与生命周期画像(lifetime profile)。读完本文,你将理解 TCMalloc 采样器的内部计数原理、权重公式的数学推导,以及采样如何与 span、pagemap、madvise、/proc页表信息协同工作。

说明:TCMalloc 是 MongoDB 服务器二进制中的内存分配器(位于src/third_party/tcmalloc/dist/,其头文件sampler.hsampler.cc等即为本仓库实际构建所用的实现,并含针对 MongoDB 的定制改动,见 allocation_sampling.h 中的MONGO HACK注释)。本文所有代码引用均以本仓库路径为准。


一、采样在 TCMalloc 中的定位

TCMalloc 使用采样来获取内存使用与分配的代表性数据(representative data)。直接对每一次分配都记录调用栈、请求大小、对齐方式等元数据成本过高,会拖慢热路径(fast path);采样的思路是:只对一小部分分配做完整记录,再用统计权重把它们“还原”成整体内存视图

在 sampler.h 的类注释中,TCMalloc 给出了采样概率的直观数据(以平均采样步长 512K 为例):

分配大小采样概率
4K约 0.00778
1MB约 0.865
1GB约 1.00000

一般地,分配大小为 X、采样标志值为 Y 时,采样概率为1 - e^(-X/Y)。这来自几何分布/指数分布的关系:每个字节以概率p = 1/profile_sampling_rate独立地被标记(Poisson 点过程),一个分配只要包含任意一个被标记的字节就会被采样,因此概率等于几何分布 CDF 的指数近似。


二、采样率(Sampling Rate)与采样点选择

2.1 默认采样率:每 2MiB

文档明确:默认情况下大约每 2MiB 采样一次,且可在代码中覆盖。这里的“每 2MiB”是统计期望(statistical expectation),而不是“每 2MiB 内存块恰好有一个被采样的字节”。

在 parameters.cc 中可以看到默认采样率的落点:

ABSL_CONST_INIT std::atomic<int64_t> Parameters::profile_sampling_rate_( kDefaultProfileSamplingRate);

Sampler::GetSamplePeriod()(见 sampler.cc)直接返回Parameters::profile_sampling_rate()。运行时可通过MallocExtension::SetProfileSamplingRate()(内部实现即MallocExtension_Internal_SetProfileSamplingRate,见 parameters.cc)动态调整。

需要注意的两个边界行为(见 sampler.cc 的PickNextSamplingPoint()):

  • 采样率 ≤ 0:表示“永远不采样”,计数器会被设为一个较大值(128 << 20,即 128MiB),以降低热路径开销;但保留在运行时可被重新开启的能力;
  • 采样率 == 1:表示“每次分配都采样”,一般仅用于测试(代价极高)。

2.2 采样点的随机生成:几何分布

PickNextSamplingPoint()的核心是GetGeometricVariable(sample_period_):生成一个均值为采样周期的几何随机变量。实现方式(见 sampler.cc):

  1. 用线性同余式 PRNG 生成随机数(先通过ExponentialBiased::NextRandom迭代 20 次“热身”);
  2. 取随机数的高 26 位得到q(范围 1 到 2^26);
  3. 通过指数分布逆 CDF 变换:interval = (log2(q) - 26) * (-log(2) * mean),得到服从均值为mean的几何分布的采样间隔;
  4. 对超大值做饱和处理,防止溢出ssize_t

实现中还有一个关键细节:kIntervalOffset = 1。旧实现是“计数器减到小于等于 0 时采样”,现在改为“计数器减到小于 0时采样”,以利用__builtin_usubl_overflow在 x86 和 ARM 上都生成良好的机器码;初始计数器值因此减 1,计算权重时再加回(见 sampler.cc)。


三、如何采样一次分配

3.1 计数器递减:从“逐字节均匀采样”到“整块分配采样”

理想的方案是让内存中每个字节以均匀概率被采样,但“逐字节”粒度太细。为了保持热路径性能,TCMalloc 采用简单计数器方案:bytes_until_sample_记录“距离下一个被标记字节还有多少字节”,每次分配就从计数器中减去该分配的大小,一旦计数器被减到小于 0,本次分配即被采样。

见 sampler.h 的热路径实现:

inline bool Sampler::TryRecordAllocationFast(size_t k) { k++; // 避免对 0 字节分配漏采 return ABSL_PREDICT_TRUE(!__builtin_usubl_overflow( bytes_until_sample_, k, reinterpret_cast<size_t*>(&bytes_until_sample_))); }

每次分配时:

  • TryRecordAllocationFast是无符号饱和减法,绝大多数情况下不会下溢,直接返回(不采样),保持热路径极快;
  • 一旦下溢,说明本次分配“包含”了被标记的字节,调用方升级到慢路径RecordAllocationSlow()完成采样决策与权重计算。

这种“整块分配采样”会带来轻微统计偏差:分配越大,其中包含多个应采样字节的概率越高。TCMalloc 在加权过程中同时修正了两个偏差——(1) 大分配可能命中多个采样点;(2) 请求大小(requested size)与实际分配大小(allocated size)可能不同。详细的加权处理见文末附录。

3.2 慢路径:RecordAllocationSlow()与权重

RecordAllocationSlow()(见 sampler.cc)决定是否采样一个分配;若采样,它返回一个权重(weight),表示该采样分配在期望上代表多少字节。

size_t weight = sample_period_ - bytes_until_sample_ - kIntervalOffset; bytes_until_sample_ = PickNextSamplingPoint(); return GetSamplePeriod() <= 0 ? 0 : weight;

k为分配大小、T为采样周期(sample_period_)、f为本次分配前计数器剩余的字节数(即减到 0 前走了多少字节),则权重为T + k - f。直观理解:如果继续每隔 T 字节采样一次,本次分配内平均还会采样(k - f) / T次,加上当前这一次共1 + (k - f)/T次,乘以平均间隔 T,即T + k - f

3.3 采样后的处理:SampleifyAllocation()

在确定要采样后,allocation_sampling.cc 的SampleifyAllocation()负责为采样分配补充完整元数据并完成后续登记,包括:

  • 记录调用栈absl::GetStackTrace)、请求大小请求对齐requested_alignment,1 会归一化为 0)、分配大小allocated_size,小对象取 size class 对应大小,大对象取 span 字节数)、访问冷热提示access_hint)、权重
  • 计算allocation_estimate = weight / (requested_size + 1),即“该采样代表的分配次数”,并据此累计内部碎片估计(sampled_internal_fragmentation_,见 allocation_sampling.cc 中stack_trace.allocated_size - requested_size的贡献);
  • 通过 allocation_sample.h 中的AllocationSampleList::ReportMalloc()将采样广播给所有处于激活状态的分配采样器
  • 通过sampled_allocation_recorder().Register()登记采样分配,并调用span->Sample(sampled_allocation)把 span 标记为已采样。

SampleifyAllocation()的签名(见 allocation_sampling.h):

sized_ptr_t SampleifyAllocation(Static& state, size_t requested_size, size_t align, size_t weight, size_t size_class, hot_cold_t access_hint, bool size_returning, void* obj, Span* span);

3.4 整页采样与 pagemap

TCMalloc 的采样粒度是tcmalloc 页(page):采样发生在 tcmalloc 页大小级别,因此每个采样都对应 pagemap 中的特定一页(见文档说明;span.cc 的Span::Sample会把该 span 标记为 sampled,并更新全局计数sampled_objects_size_total_sampled_count_)。

3.5 小分配:真实对象 + 代理对象

对于小分配,TCMalloc 会做两次分配:

  1. 返回给调用者的分配:独占整个 tcmalloc 页,不与其他分配共享;
  2. 代理对象(proxy)分配:位于一个未被采样的 span中,用于计算碎片画像(fragmentation profiles)时代表该对象与其他对象的共存关系。

在 allocation_sampling.cc 中可见该逻辑:当 span 中对象数objects_per_span != 1时,stack_trace.proxy = obj,真实对象被释放回缓存(FreeProxyObject),而采样返回的是独占的新 span 起始地址。

3.6 采样分配与 THP:madvise(MADV_NOHUGEPAGE)

当分配被采样时,其关联的虚拟地址会被以MADV_NOHUGEPAGE标志madvise(见文档对 system-alloc.cc 的引用)。结合上面的整页行为,这意味着:

每个采样分配都拥有自己独立的 OS 页(native page),不与其他任何分配共享。

这一特性直接影响了后续的换页行为与画像准确性(见第六节)。


四、如何释放采样对象

每个采样分配都会被打上标记(tagged)。借助该标记,TCMalloc 可以快速判断某次释放是否可能涉及采样分配,从而决定是否走慢路径。

当采样 span 生命周期结束时,通过tcmalloc::Span::Unsample()释放(见 span.cc):

SampledAllocation* Span::Unsample() { if (!sampled_) { return nullptr; } sampled_ = 0; SampledAllocation* sampled_allocation = sampled_allocation_; sampled_allocation_ = nullptr; // 更新全局采样字节计数 tc_globals.sampled_objects_size_.Add(neg_allocated_bytes); return sampled_allocation; }

对应地,释放路径上的入口是 allocation_sampling.cc 的MaybeUnsampleAllocation():它调用span->Unsample()取出采样记录,随后:

  • 校验释放大小与请求大小是否一致(size-returning 分配允许requested_size <= size <= allocated_size),不一致会触发 GWP-ASan 风格的“mismatched-size-delete”报告;
  • allocation_estimate * (allocated_size - requested_size)从内部碎片估计中扣回;
  • 向生命周期采样器报告释放事件(deallocation_samples.ReportFree);
  • 若存在代理对象,则按代理所在的 size class 将其释放回 CPU 缓存 / 线程缓存 / 传输缓存。

五、堆画像与碎片画像:遍历采样列表

堆画像(heap profile)与碎片画像(fragmentation profile)的实现非常直接:遍历所有已采样的对象列表,分别计算它们消耗的堆内存量,或(借助代理对象)计算碎片程度。

对应实现见 allocation_sampling.cc:

  • DumpHeapProfile():遍历sampled_allocation_recorder()中的每个SampledAllocation,将sampled_stack以计数 1.0 加入StackTraceTable(ProfileType::kHeap);
  • DumpFragmentationProfile():对于带代理对象的采样(t.proxy != nullptr),通过PageIdContaining(t.proxy)找到代理所在的 span,调用span->Fragmentation(t.allocated_size)计算碎片并计入画像;若每个 span 只有一个对象,则相邻 span 可被释放回系统,不产生碎片贡献。

5.1 堆画像中的驻留信息(residency)

每个分配在堆画像中暴露时还会携带额外元数据。在写入堆画像前的准备阶段,profile_builder.cc 的MergeProfileSamplesAndMaybeGetResidencyInfo()探测操作系统,判断采样分配底层的内存页是否被换出(swapped out)或完全不驻留(not resident,例如从未被写入过):

SampleMergedMap MergeProfileSamplesAndMaybeGetResidencyInfo( const tcmalloc::Profile& profile, PageFlags* pageflags, Residency* residency) { SampleMergedMap map; profile.Iterate(& { SampleMergedData& data = map[entry]; data.count += entry.count; data.sum += entry.sum; if (residency) { auto residency_info = residency->Get(entry.span_start_address, entry.allocated_size); ...

该信息通过读取/proc/pid/pagemap获取每个底层 OS 页的状态。可以看到MakeProfileProto中只有kHeap类型画像才会实例化PageFlagsResidency(见 profile_builder.cc),因此驻留信息是堆画像特有的附加维度。

5.2 采样分配更容易被换出

文档特别指出一个设计上的“幸运副产品”:OS 对采样分配的换页(swap)行为比统计显示的更激进。原因是采样分配不与任何其他分配共享内存页(无论普通页还是大页)。因此,一个采样且很少被访问的分配会比未采样分配更早被回收——后者可能与其他被高频访问的分配共享页面。也就是说,采样在识别“可被独立换出的特定分配”方面具有额外价值,这与其内存分配行为无关。

关于页面标志位(pageflags)还有更丰富的信息来源,但读取/proc/kpageflags需要root 权限。要将其提供给 TCMalloc,需要合入相应的内核补丁(文档引用了 Linux-mm 邮件列表上提议的内核改动)。


六、分配画像(Allocation Profiling)

分配画像报告一段时间内采样分配的列表。使用方式(见 malloc_extension.h):

  1. 调用MallocExtension::StartAllocationProfiling()开启画像,得到一个 token;
  2. 等待一段时间;
  3. 调用 token 的Stop方法,取得并报告画像。

在 allocation_sample.h 中可以看到其机制:AllocationSampleAllocationProfilingTokenBase的子类,内部持有一个StackTraceTable mallocs_AllocationSampleList维护一个激活采样器链表

  • 当分配画像器激活时,它被Add()加入链表的头部;
  • 当 token 被Stop()认领(claimed)时,从链表中移除;
  • 每次发生采样分配时,ReportMalloc()会遍历整个链表,把该采样的StackTrace以计数 1.0 加入每个激活画像器的表中。

也就是说,激活的分配画像器越多,每次采样分配广播的开销越大;链表通常很短,且不在热路径上(AllocationSampleList的注释明确说明“This list is very short and we're nowhere near a hot path”)。


七、生命周期画像(Lifetime Profiling)

生命周期画像报告对象生命周期的列表,每个生命周期由一对“分配记录 + 释放记录”组成。使用方式:

  1. 调用MallocExtension::StartLifetimeProfiling()开启画像,得到一个 token;
  2. 持续运行,直到调用 token 的Stop

关键限制:只有当某个对象的分配和释放都发生在画像激活期间,它的生命周期才会被报告。

机制上,allocation_sampling.cc 的SampleifyAllocation()在登记采样时会调用state.deallocation_samples.ReportMalloc(stack_trace)(见allocation_sample.hReportMalloc),把“分配事件”通知给生命周期采样器;而MaybeUnsampleAllocation()在释放时调用state.deallocation_samples.ReportFree(sampled_alloc_handle),把“释放事件”与之前的分配记录配对,从而形成完整的生命周期记录。

文档还指出,基于采样的生命周期画像器的详细描述可参考学术论文"Learning-based Memory Allocation for C++ Server Workloads"(ASPLOS 2020,第 4 节)——这正是采样机制在真实服务器负载(如 MongoDB 这类 C++ 服务)上的研究背景。


八、附录:加权过程的详细推导

8.1 距离计数器采样及其简化

设采样周期为 T。理想情况下,我们希望分配内存的每个字节都以恒定概率p = 1/T被采样。相邻两个被采样字节之间的距离服从 N 上的几何分布;实际代码取的是参数 λ = 1/T 的指数分布变量的向上取整(两者等价)。

每次分配时,从初始化为该随机变量一次实现的计数器上减去“请求大小 + 1”r + 1;为何 +1 见 8.3 节)。如果所有分配都是 1 字节宽,这个方案完美成立:每个被采样的字节代表 T 字节,且每个内存字节被采样的概率均匀。可惜实际分配通常大于 1 字节,因此需要补偿。

考虑一个被选中采样的分配(大小为 k)。设采样前计数器值为 f,其中第 f 个字节(图中*标记)把计数器减到 0:

| 1 | 2 | ... | f-1 | * | f+1 | ... | k | \___________f 个字节__________/ \___ k-f 个字节 ___/

该分配的采样权重 W(注意:不是MallocHook::SampledAlloc中报告的weight即“分配计数估计”)是“该采样代表的字节数”。被标记的*字节贡献 T 字节(平均采样间隔);*之前的字节明确未被采样,不贡献权重。

*之后的k - f个字节各自仍有 1/T 的概率被采样。若继续采样,剩余的X个字节会被采到,其中

$$X \sim \mathrm{Pois}\left(\frac{k-f}{T}\right)$$

(由泊松分布的定义得出)。因此每个采样分配的权重为

$$W = T + TX.$$

为避免实现一个 X 的实例(计算代价高),此处做简化:取 X 为其期望值:

$$W = T + T\hat X = T + T\left(\frac{k-f}{T}\right) = T + k - f.$$

8.2 总内存估计的方差分析

对总内存使用的估计为

$$M = \sum_i W_i$$

其中求和覆盖所有采样分配。

  • 在未做上述简化假设的世界里,或当所有分配都是 1 字节时,不存在k - f项,方差就是底层泊松过程的方差:

$$\sigma^2 = \lambda = TM.$$

  • 在另一个极端——只有一个巨型分配k = M >> T时,方差只取决于几何随机变量的一次实现:

$$\sigma^2 = \frac{1-p}{p^2} = \frac{1 - 1/T}{1/T^2} = T^2 - T.$$

因此,取决于分配模式,M 估计的方差在T^2 - TTM之间变化。

8.3 请求大小与实际分配大小

为了让热路径保持快速,上述所有逻辑都基于请求大小 + 1,而非实际分配大小:

  • 对于大分配(文档写作时指大于 262144 字节的分配),请求大小与实际分配大小几乎只差 1 字节;
  • 对于小分配,分配会被分桶(bucketed)为离散的 size class,以获得最佳缓存性能;
  • 对于最小的 0 字节分配,TCMalloc 实际仍会分配内存(向上取整到最小 size class),因此必须保证 0 字节分配也有被采样的可能。做法是对所有请求大小加 1,得到“requested plus one”。

当一个分配被选中采样时,报告的是分配计数估计MallocHook::SampledAlloc中的weight,含义是“该采样代表多少次分配”):取上一节的采样权重 W,除以实际请求大小加 1(注意不是分配大小)。因此单个采样分配代表的字节数估计为

$$\frac{a}{r+1} W$$

其中r是请求大小,a是实际分配大小,W是上一节的分配权重。

直觉示例:假设只有 1 字节请求的分配(当前会被向上取整为 8 字节实际分配)。每次分配把距离计数器减去 2(即r+1),而不是真实的 8 字节。当一个分配被选中采样时,再乘以相同的去偏比率 4(即分配大小 8 除以请求大小加 1 的 2)。

本质上,这里做的是以低于请求采样率的可变速率采样,速率取决于请求大小与分配大小的比值。这会增大“离下一个 size class 几何距离较远”的分配的方差,但不会改变分布的基础期望——分布的**无记忆性(memorylessness)**保证:没有任何分配模式能导致“请求/分配比”显著不同的分配被过度或不足上报。

在极端情况下(所有分配都远小于最小 size class),该方法退化为“每次分配采样”策略。每次分配采样策略对大分配会产生更高方差;而此处实际的 size class 分布覆盖了所有较大分配,方差项只会增加很小的量。


九、在本仓库中进一步探索

  • sampling.md:本文对应的官方采样机制文档;
  • sampler.h 与 sampler.cc:采样器实现与几何分布随机数生成;
  • allocation_sampling.h 与 allocation_sampling.cc:SampleifyAllocationMaybeUnsampleAllocation、堆/碎片画像转储;
  • allocation_sample.h:分配画像与生命周期画像的激活采样器链表;
  • span.cc 与 span.h:Span::Sample/Span::Unsample与全局采样统计;
  • profile_builder.cc:堆画像写入前的样本合并与驻留信息探测(/proc/pid/pagemap);
  • parameters.cc:默认采样率与SetProfileSamplingRate的底层实现;
  • size_classes.cc:小分配的分桶 size class。

通过以上源码,可以完整地串起 TCMalloc 采样的一条链路:分配发生 → 计数器递减(热路径)→ 下溢触发慢路径 → 权重计算 → 采样元数据采集 → span 标记与全局计数 → 画像器广播 → 释放时 Unsample → 画像输出,这也正是堆、碎片、分配与生命周期四类画像共同的底层基础设施。

【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询