ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

TCMalloc 采样机制深度解析:从分配采样到堆/碎片/生命周期画像 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.h、sampler.cc等即为本仓库实际构建所用的实现并含针对 MongoDB 的定制改动见 allocation_sampling.h 中的MONGO HACK注释。本文所有代码引用均以本仓库路径为准。一、采样在 TCMalloc 中的定位TCMalloc 使用采样来获取内存使用与分配的代表性数据representative data。直接对每一次分配都记录调用栈、请求大小、对齐方式等元数据成本过高会拖慢热路径fast path采样的思路是只对一小部分分配做完整记录再用统计权重把它们“还原”成整体内存视图。在 sampler.h 的类注释中TCMalloc 给出了采样概率的直观数据以平均采样步长 512K 为例分配大小采样概率4K约 0.007781MB约 0.8651GB约 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::atomicint64_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用线性同余式 PRNG 生成随机数先通过ExponentialBiased::NextRandom迭代 20 次“热身”取随机数的高 26 位得到q范围 1 到 2^26通过指数分布逆 CDF 变换interval (log2(q) - 26) * (-log(2) * mean)得到服从均值为mean的几何分布的采样间隔对超大值做饱和处理防止溢出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_castsize_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_alignment1 会归一化为 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.hsized_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 整页采样与 pagemapTCMalloc 的采样粒度是tcmalloc 页page采样发生在 tcmalloc 页大小级别因此每个采样都对应 pagemap 中的特定一页见文档说明span.cc 的Span::Sample会把该 span 标记为 sampled并更新全局计数sampled_objects_size_与total_sampled_count_。3.5 小分配真实对象 代理对象对于小分配TCMalloc 会做两次分配返回给调用者的分配独占整个 tcmalloc 页不与其他分配共享代理对象proxy分配位于一个未被采样的 span中用于计算碎片画像fragmentation profiles时代表该对象与其他对象的共存关系。在 allocation_sampling.cc 中可见该逻辑当 span 中对象数objects_per_span ! 1时stack_trace.proxy obj真实对象被释放回缓存FreeProxyObject而采样返回的是独占的新 span 起始地址。3.6 采样分配与 THPmadvise(MADV_NOHUGEPAGE)当分配被采样时其关联的虚拟地址会被以MADV_NOHUGEPAGE标志madvise见文档对 system-alloc.cc 的引用。结合上面的整页行为这意味着每个采样分配都拥有自己独立的 OS 页native page不与其他任何分配共享。这一特性直接影响了后续的换页行为与画像准确性见第六节。四、如何释放采样对象每个采样分配都会被打上标记tagged。借助该标记TCMalloc 可以快速判断某次释放是否可能涉及采样分配从而决定是否走慢路径。当采样 span 生命周期结束时通过tcmalloc::Span::Unsample()释放见 span.ccSampledAllocation* 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.ccDumpHeapProfile()遍历sampled_allocation_recorder()中的每个SampledAllocation将sampled_stack以计数 1.0 加入StackTraceTableProfileType::kHeapDumpFragmentationProfile()对于带代理对象的采样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类型画像才会实例化PageFlags与Residency见 profile_builder.cc因此驻留信息是堆画像特有的附加维度。5.2 采样分配更容易被换出文档特别指出一个设计上的“幸运副产品”OS 对采样分配的换页swap行为比统计显示的更激进。原因是采样分配不与任何其他分配共享内存页无论普通页还是大页。因此一个采样且很少被访问的分配会比未采样分配更早被回收——后者可能与其他被高频访问的分配共享页面。也就是说采样在识别“可被独立换出的特定分配”方面具有额外价值这与其内存分配行为无关。关于页面标志位pageflags还有更丰富的信息来源但读取/proc/kpageflags需要root 权限。要将其提供给 TCMalloc需要合入相应的内核补丁文档引用了 Linux-mm 邮件列表上提议的内核改动。六、分配画像Allocation Profiling分配画像报告一段时间内采样分配的列表。使用方式见 malloc_extension.h调用MallocExtension::StartAllocationProfiling()开启画像得到一个 token等待一段时间调用 token 的Stop方法取得并报告画像。在 allocation_sample.h 中可以看到其机制AllocationSample是AllocationProfilingTokenBase的子类内部持有一个StackTraceTable mallocs_。AllocationSampleList维护一个激活采样器链表当分配画像器激活时它被Add()加入链表的头部当 token 被Stop()认领claimed时从链表中移除每次发生采样分配时ReportMalloc()会遍历整个链表把该采样的StackTrace以计数 1.0 加入每个激活画像器的表中。也就是说激活的分配画像器越多每次采样分配广播的开销越大链表通常很短且不在热路径上AllocationSampleList的注释明确说明“This list is very short and were nowhere near a hot path”。七、生命周期画像Lifetime Profiling生命周期画像报告对象生命周期的列表每个生命周期由一对“分配记录 释放记录”组成。使用方式调用MallocExtension::StartLifetimeProfiling()开启画像得到一个 token持续运行直到调用 token 的Stop。关键限制只有当某个对象的分配和释放都发生在画像激活期间它的生命周期才会被报告。机制上allocation_sampling.cc 的SampleifyAllocation()在登记采样时会调用state.deallocation_samples.ReportMalloc(stack_trace)见allocation_sample.h的ReportMalloc把“分配事件”通知给生命周期采样器而MaybeUnsampleAllocation()在释放时调用state.deallocation_samples.ReportFree(sampled_alloc_handle)把“释放事件”与之前的分配记录配对从而形成完整的生命周期记录。文档还指出基于采样的生命周期画像器的详细描述可参考学术论文Learning-based Memory Allocation for C Server WorkloadsASPLOS 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 | * | f1 | ... | 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 - T与TM之间变化。8.3 请求大小与实际分配大小为了让热路径保持快速上述所有逻辑都基于请求大小 1而非实际分配大小对于大分配文档写作时指大于 262144 字节的分配请求大小与实际分配大小几乎只差 1 字节对于小分配分配会被分桶bucketed为离散的 size class以获得最佳缓存性能对于最小的 0 字节分配TCMalloc 实际仍会分配内存向上取整到最小 size class因此必须保证 0 字节分配也有被采样的可能。做法是对所有请求大小加 1得到“requested plus one”。当一个分配被选中采样时报告的是分配计数估计MallocHook::SampledAlloc中的weight含义是“该采样代表多少次分配”取上一节的采样权重 W除以实际请求大小加 1注意不是分配大小。因此单个采样分配代表的字节数估计为$$\frac{a}{r1} W$$其中r是请求大小a是实际分配大小W是上一节的分配权重。直觉示例假设只有 1 字节请求的分配当前会被向上取整为 8 字节实际分配。每次分配把距离计数器减去 2即r1而不是真实的 8 字节。当一个分配被选中采样时再乘以相同的去偏比率 4即分配大小 8 除以请求大小加 1 的 2。本质上这里做的是以低于请求采样率的可变速率采样速率取决于请求大小与分配大小的比值。这会增大“离下一个 size class 几何距离较远”的分配的方差但不会改变分布的基础期望——分布的**无记忆性memorylessness**保证没有任何分配模式能导致“请求/分配比”显著不同的分配被过度或不足上报。在极端情况下所有分配都远小于最小 size class该方法退化为“每次分配采样”策略。每次分配采样策略对大分配会产生更高方差而此处实际的 size class 分布覆盖了所有较大分配方差项只会增加很小的量。九、在本仓库中进一步探索sampling.md本文对应的官方采样机制文档sampler.h 与 sampler.cc采样器实现与几何分布随机数生成allocation_sampling.h 与 allocation_sampling.ccSampleifyAllocation、MaybeUnsampleAllocation、堆/碎片画像转储allocation_sample.h分配画像与生命周期画像的激活采样器链表span.cc 与 span.hSpan::Sample/Span::Unsample与全局采样统计profile_builder.cc堆画像写入前的样本合并与驻留信息探测/proc/pid/pagemapparameters.cc默认采样率与SetProfileSamplingRate的底层实现size_classes.cc小分配的分桶 size class。通过以上源码可以完整地串起 TCMalloc 采样的一条链路分配发生 → 计数器递减热路径→ 下溢触发慢路径 → 权重计算 → 采样元数据采集 → span 标记与全局计数 → 画像器广播 → 释放时 Unsample → 画像输出这也正是堆、碎片、分配与生命周期四类画像共同的底层基础设施。【免费下载链接】mongoThe MongoDB Database项目地址: https://gitcode.com/GitHub_Trending/mo/mongo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表