ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Apache Arrow C++ 内存管理实战指南:Buffer、MemoryPool、Device 与 Linux 内存剖析

Apache Arrow C++ 内存管理实战指南:Buffer、MemoryPool、Device 与 Linux 内存剖析 Apache Arrow C 内存管理实战指南Buffer、MemoryPool、Device 与 Linux 内存剖析【免费下载链接】arrowApache Arrow is a multi-language toolbox for accelerated data interchange and in-memory processing项目地址: https://gitcode.com/gh_mirrors/arrow13/arrow本文是一份面向 C 开发者的 Apache Arrow 内存管理深度指南完整覆盖 Arrow C 库的内存抽象体系以arrow::Buffer为核心的无类型内存容器、基于arrow::MemoryPool的统一分配池jemalloc/mimalloc/system 三档后端与ARROW_DEFAULT_MEMORY_POOL覆盖机制、面向 GPU 等异构硬件的Device/MemoryManager设备无关编程模型以及在 Linux 上基于perf的零改动内存分配剖析工作流。读完本文你将掌握 Arrow C 中从分配一块内存到构建数组底层缓冲再到定位内存泄漏与分配热点的完整技术栈。文中所有结论均以当前仓库源码cpp/src/arrow为证据代码示例可直接复制使用。Buffer贯穿 Arrow 数据管线的无类型内存抽象为什么需要 BufferArrow C 中的数据传递不依赖裸指针加长度这类散装约定而是统一封装为arrow::Buffer。根据 buffer.h 的类注释一个 Buffer 包含指向一段连续内存的指针及其大小并具备两个相关长度概念size包含有效数据的字节数capacity为缓冲分配的总字节数。类定义中保证的不变量是Size Capacity。Buffer 本身不拥有内存但子类通常拥有因此其生命周期总是与底层内存提供方绑定——一个 Buffer 在其析构之前应当始终指向有效内存。Buffer 是无类型的它只描述一块物理内存区域不管这块内存将来被解释为 int32 数组、字符串偏移量还是位图。这种抽象的价值在于Buffer 既可以由 Arrow 自身分配也可以由第三方例程分配。例如可以把一个 Python bytestring 的数据包成 Arrow Buffer并在需要时保持 Python 对象存活文档中明确指出该用法。此外 Buffer 还有多种形态可变/不可变、可扩容/不可扩容。典型工作流是构建数据时持有可变 Buffer待数据成型后冻结为不可变容器如 Array。需要特别警惕的是部分 Buffer 可能指向非 CPU 内存如 CUDA 上下文提供的 GPU 显存。GPU-aware 应用绝不能把 GPU 指针当作 CPU 可访问指针来解释反之亦然。这正是后文 Device 抽象要解决的问题。访问 Buffer 内存Buffer 通过size()和data()访问器提供对底层内存的快速访问对可变 Buffer 的写访问则使用mutable_data()uint8_t* data buffer-mutable_data(); // 可写访问 const uint8_t* data buffer-data(); // 只读访问 int64_t size buffer-size();从源码看Buffer 的只读构造Buffer(const uint8_t* data, int64_t size)在 buffer.h 中会将is_mutable_置为 false、is_cpu_置为 true并绑定到默认 CPU 内存管理器还提供了从std::string_view零拷贝构造、以及从父 Buffer offset size构造子 Bufferbuffer.h等重载——后者正是切片功能的实现基础。零拷贝切片Slicing对 Buffer 做切片不会复制数据而是得到一个引用底层数据连续子集的零拷贝视图。调用arrow::SliceBuffer与arrow::SliceMutableBuffer即可auto slice arrow::SliceBuffer(buffer, offset, length); // 只读切片 auto mslice arrow::SliceMutableBuffer(mutable_buffer, off, len); // 可变切片对应实现位于 buffer.ccSliceMutableBuffer内部构造MutableBuffer(parent, offset, length)通过parent-mutable_address() offset计算新指针并DCHECK(parent-is_mutable())断言父缓冲必须可变——这意味着对不可变 Buffer 调用可变切片会在 debug 构建中直接断言失败。切片 Buffer 通过parent_成员持有父 Buffer 的shared_ptr从而保证父缓冲的内存在切片存活期间不会被释放。分配一个 Buffer调用arrow::AllocateBuffer或arrow::AllocateResizableBuffer的重载即可自行分配后者支持后续Resizearrow::Resultstd::unique_ptrBuffer maybe_buffer arrow::AllocateBuffer(4096); if (!maybe_buffer.ok()) { // ... handle allocation error } std::shared_ptrarrow::Buffer buffer *std::move(maybe_buffer); uint8_t* buffer_data buffer-mutable_data(); memcpy(buffer_data, hello world, 11);这样分配的 Buffer 保证64 字节对齐并带有 Arrow 内存布局规范 推荐的 padding。对齐常量在 type_fwd.h 中定义为kDefaultBufferAlignment 64AllocateResizableBuffer等函数也接受显式的alignment参数默认即 64。Buffer 的 padding 处理由ZeroPadding()完成buffer.h它把 size 与 capacity 之间的空隙清零BufferBuilder::Finish在产出 Buffer 前会自动调用它。用 BufferBuilder 增量构建 Buffer当需要边分配边构建时使用arrow::BufferBuilderBufferBuilder builder; builder.Resize(11); // reserve enough space for 11 bytes builder.Append(hello , 6); builder.Append(world, 5); auto maybe_buffer builder.Finish(); if (!maybe_buffer.ok()) { // ... handle buffer allocation error } std::shared_ptrarrow::Buffer buffer *maybe_buffer;从 buffer_builder.h 的实现看Builder 的关键机制包括Resize(new_capacity, shrink_to_fit true)首次调用时通过AllocateResizableBuffer分配之后委托给底层 Buffer 的Resize容量会被向上取整到 64 字节的倍数以保证 padding。Reserve(additional_bytes)仅在容量不足时才触发Resize避免无谓的重分配。GrowByFactor采用2 倍容量增长策略std::max(new_capacity, current_capacity * 2)。注释buffer_builder.h指出2x 增长在 jemalloc 下略优、在系统分配器下显著更优详见 ARROW-6450 的讨论。Finish(shrink_to_fit true)将容量收缩到实际大小、调用ZeroPadding()清零 padding 区然后重置 Builder 以便复用。用 TypedBufferBuilder 构建定宽类型 Buffer如果 Buffer 用于存放某种定宽类型的值例如 List 数组的 32 位 offsets使用模板类arrow::TypedBufferBuilderT更便利TypedBufferBuilderint32_t builder; builder.Reserve(2); // reserve enough space for two int32_t values builder.Append(0x12345678); builder.Append(-0x765643210); auto maybe_buffer builder.Finish(); if (!maybe_buffer.ok()) { // ... handle buffer allocation error } std::shared_ptrarrow::Buffer buffer *maybe_buffer;Reserve(N)预留给定个数的元素内部换算为N * sizeof(T)字节Append逐元素写入并自动扩容Finish产出定长类型对齐的 Buffer。MemoryPool统一的内存分配池分配背后的统一出口使用 Arrow C API 分配 Buffer 时底层内存由某个arrow::MemoryPool实例分配。默认情况下这是进程级默认内存池但许多 Arrow API 允许传入其他MemoryPool*用于其内部分配。内存池的定位是大而长命的数据如数组缓冲而小型 C 对象与临时工作区通常仍走常规 C 分配器。MemoryPool接口memory_pool.h还暴露了一组统计方法便于观测分配行为bytes_allocated()当前已分配字节数max_memory()历史最大分配量未知时返回 -1total_bytes_allocated()累计分配总量num_allocations()累计分配/重分配次数backend_name()后端名称如system、jemalloc。此外 memory_pool.h 还提供了两个包装类LoggingMemoryPool记录每次分配的日志包装与ProxyMemoryPool跟踪直接经由其调用的字节数与峰值实际分配委托给底层池。默认内存池的选择算法默认内存池取决于编译选项优先顺序如下若编译期启用了jemalloc使用 jemalloc 堆否则若编译期启用了mimalloc使用 mimalloc 堆否则使用 C 库malloc堆。源码 memory_pool.cc 中的SupportedBackends()揭示了一个重要细节在 Apple 平台__APPLE__上 mimalloc 排在 jemalloc 之前而在非 Apple 平台 jemalloc 优先对应 ARROW-12316 的修复。DefaultBackend()memory_pool.cc在没有用户指定时取支持列表的第一个后端。值得注意的实现细节UserSelectedBackend()与SupportedBackends()都采用函数内静态单例而非全局常量memory_pool.cc。这是因为在部分场景尤其是 R 绑定中default_memory_pool()可能在所有全局变量初始化完成之前被调用若使用全局常量会导致ARROW_DEFAULT_MEMORY_POOL环境变量被忽略对应 ARROW-12248。用环境变量覆盖默认内存池可以通过设置ARROW_DEFAULT_MEMORY_POOL环境变量覆盖上述选择算法export ARROW_DEFAULT_MEMORY_POOLsystem # 强制使用 C malloc export ARROW_DEFAULT_MEMORY_POOLjemalloc # 强制 jemalloc需编译期启用 export ARROW_DEFAULT_MEMORY_POOLmimalloc # 强制 mimalloc需编译期启用从源码memory_pool.cc看该变量的解析逻辑是值为空字符串时视同未设置若值与编译期启用的后端之一匹配则采用之若指定了未启用的后端会打印 WARNING 日志列出所有受支持后端并回退到默认算法。因此设置一个未编译进库的后端不会报错只会告警并静默回退——排查问题时留意日志即可。STL 集成双向打通Arrow 提供与 C STL 分配器的双向集成两者都实现在 stl_allocator.h方向一用 Arrow 内存池喂 STL 容器—— 使用arrow::stl::allocatorT包装器。它默认绑定default_memory_pool()也支持显式指定MemoryPool*stl_allocator.hallocate/deallocate分别转发到pool_-Allocate/pool_-Free。例如// 让 std::vector 的内存来自 Arrow 默认内存池 std::vectorint32_t, arrow::stl::allocatorint32_t values;这在 Arrow 自身代码库中就有真实使用hash_aggregate.cccpp/src/arrow/compute/kernels/hash_aggregate.cc多处使用arrow::stl::allocatorchar管理哈希表内存CSV writercpp/src/arrow/csv/writer.cc也用arrow::stl::allocatorchar*承载偏移量向量确保聚合计算产生的内存全部计入 Arrow 内存池的统计。方向二用 STL 分配器喂 Arrow 内存—— 使用arrow::stl::STLMemoryPool类它把任意 STL allocator 包装成一个MemoryPool。注意其Reallocate实现stl_allocator.h是新分配 memcpy 释放旧指针因为STL allocator 不提供 resize 操作所以这种方案性能可能较差——每次扩容都是全量复制适合对性能不敏感、但希望复用既有分配器策略的场景。Device 与 MemoryManager异构设备上的内存抽象基本概念大多数 Arrow 应用只访问主机CPU内存但有些场景需要在设备内存如 GPU 显存与主机内存之间统一处理。Arrow 用arrow::Device抽象表示 CPU 及其他设备用arrow::MemoryManager描述如何在指定设备上分配内存。每个设备都有一个默认内存管理器也可以构造额外实例例如在 CPU 上包装一个自定义MemoryPool。从 device.h 看设备类型由DeviceAllocationType枚举统一标识与 C Data 接口的设备类型对应kCPU1、kCUDA2、kCUDA_HOST3、kOPENCL4、kVULKAN7、kMETAL8、kVPI9、kROCM10、kROCM_HOST11、kEXT_DEV12、kCUDA_MANAGED13、kONEAPI14、kWEBGPU15、kHEXAGON16。Device抽象接口device.h提供type_name()、ToString()、Equals()、device_id()、is_cpu()、device_type()以及返回默认内存管理器的default_memory_manager()此外还包含实验性的Stream与SyncEvent同步原语device.h用于流式顺序事件与内存分配。CPU 设备的默认内存管理器在 device.cc 中实现为单例CPUMemoryManager::Make(CPUDevice::Instance(), default_memory_pool())——即把进程级默认内存池包装成 CPU 内存管理器CPUDevice::memory_manager(pool)device.cc则允许为任意非默认MemoryPool构造新的 CPU 内存管理器。设备无关编程当从第三方代码收到一个 Buffer 时可用is_cpu()查询它是否可被 CPU 读取Buffer::is_cpu在构造时由内存管理器决定。进而可以用泛化方式把 Buffer 映射到指定设备上arrow::Buffer::View在目标设备上构造一个访问同一内容的地址。若源与目标设备相同则为 no-op否则由设备相关机制尝试构造目标设备可访问的地址实际的设备间数据传输可能延迟到读取内容时才发生。arrow::Buffer::ViewOrCopy优先尝试 View失败则回退为完整拷贝。其实现buffer.cc正是先ViewBuffer不 OK 再CopyBuffer的两步策略。典型用法是把任意 Buffer 变成 CPU 可见的视图或副本std::shared_ptrarrow::Buffer arbitrary_buffer ... ; std::shared_ptrarrow::Buffer cpu_buffer arrow::Buffer::ViewOrCopy( arbitrary_buffer, arrow::default_cpu_memory_manager());类似地如果要在不假设 Buffer 可被 CPU 读取的前提下做 I/O可以调用arrow::Buffer::GetReader和arrow::Buffer::GetWriterbuffer.cc两者分别委托给内存管理器的GetBufferReader/GetBufferWriter其中GetWriter会校验 Buffer 必须是可变的否则返回Status::Invalid。Memory Profiling基于 Linux perf 的零改动分配剖析在 Linux 上无需修改任何二进制即可用perf record生成细粒度的内存分配剖析报告——它不仅能给出分配大小还能给出完整调用栈traceback。前提是二进制带有调试符号debug 构建或带 debug symbols 的 release 构建。非 Linux 平台怎么办若需在其他平台上剖析 Arrow 的测试程序可以用 Archery 启动 Linux Docker 容器archery docker run ubuntu-cpp bash # Inside the Docker container... /arrow/ci/scripts/cpp_build.sh /arrow /build cd build/cpp/debug ./arrow-array-test # Run a test apt-get update apt-get install -y linux-tools-generic alias perf/usr/lib/linux-tools/version-path/perf第一步为分配器方法设置 probe 点在使用的分配器方法上创建 probe 点。采集$params可记录请求的分配大小采集$retval可记录分配的地址从而将地址与后续的 free/释放调用关联起来。jemalloc 后端perf probe -x libarrow.so je_arrow_mallocx $params perf probe -x libarrow.so je_arrow_mallocx%return $retval perf probe -x libarrow.so je_arrow_rallocx $params perf probe -x libarrow.so je_arrow_rallocx%return $retval perf probe -x libarrow.so je_arrow_dallocx $params PROBE_ARGS-e probe_libarrow:je_arrow_mallocx \ -e probe_libarrow:je_arrow_mallocx__return \ -e probe_libarrow:je_arrow_rallocx \ -e probe_libarrow:je_arrow_rallocx__return \ -e probe_libarrow:je_arrow_dallocxmimalloc 后端perf probe -x libarrow.so mi_malloc_aligned $params perf probe -x libarrow.so mi_malloc_aligned%return $retval perf probe -x libarrow.so mi_realloc_aligned $params perf probe -x libarrow.so mi_realloc_aligned%return $retval perf probe -x libarrow.so mi_free $params PROBE_ARGS-e probe_libarrow:mi_malloc_aligned \ -e probe_libarrow:mi_malloc_aligned__return \ -e probe_libarrow:mi_realloc_aligned \ -e probe_libarrow:mi_realloc_aligned__return \ -e probe_libarrow:mi_free第二步用 perf record 采集设置好 probe 后用perf record连同调用栈一起记录。以下示例剖析 Arrow 的 StructArray 单元测试perf record -g --call-graph dwarf \ $PROBE_ARGS \ ./arrow-array-test --gtest_filterStructArray*若要剖析一个正在运行的进程可以perf record -p PID一直记录到 CTRLC 中断或者用perf record -P PID sleep 10固定记录 10 秒。第三步解析事件流产出的数据可用标准 perf 工具链处理也可用perf script转成文本并交给自定义脚本。下面的脚本把perf script输出解析为每行一个 JSON 对象的流便于后续程序化处理# process_perf_events.py import sys import re import json # Example non-traceback line # arrow-array-tes 14344 [003] 7501.073802: probe_libarrow:je_arrow_mallocx: (7fbcd20bb640) size0x80 flags6 current {} current_traceback def new_row(): global current_traceback current[traceback] current_traceback print(json.dumps(current)) current_traceback for line in sys.stdin: if line \n: continue elif line[0] \t: # traceback line current_traceback line.strip(\t) else: line line.rstrip(\n) if not len(current) 0: new_row() parts re.sub( , , line).split( ) parts.reverse() parts.pop() # file parts.pop() # 14344 parts.pop() # [003] current[time] float(parts.pop().rstrip(:)) current[event] parts.pop().rstrip(:) parts.pop() # (7fbcd20bddf0) if parts[-1] -: parts.pop() parts.pop() params {} for pair in parts: key, value pair.split() params[key] value current[params] params调用方式与输出预览$ perf script | python3 /arrow/process_perf_events.py processed_events.jsonl $ head processed_events.jsonl | cut -c -120 {time: 14814.954378, event: probe_libarrow:je_arrow_mallocx, params: {flags: 6, size: 0x80}, traceback {time: 14814.95443, event: probe_libarrow:je_arrow_mallocx__return, params: {arg1: 0x7f4a97e09000}, traceba {time: 14814.95448, event: probe_libarrow:je_arrow_mallocx, params: {flags: 6, size: 0x40}, traceback: {time: 14814.954486, event: probe_libarrow:je_arrow_mallocx__return, params: {arg1: 0x7f4a97e0a000}, traceb {time: 14814.954502, event: probe_libarrow:je_arrow_rallocx, params: {flags: 6, size: 0x40, ptr: 0x7f {time: 14814.954507, event: probe_libarrow:je_arrow_rallocx__return, params: {arg1: 0x7f4a97e0a040}, traceb {time: 14814.954796, event: probe_libarrow:je_arrow_mallocx, params: {flags: 6, size: 0x40}, traceback {time: 14814.954805, event: probe_libarrow:je_arrow_mallocx__return, params: {arg1: 0x7f4a97e0a080}, traceb {time: 14814.954817, event: probe_libarrow:je_arrow_mallocx, params: {flags: 6, size: 0x40}, traceback {time: 14814.95482, event: probe_libarrow:je_arrow_mallocx__return, params: {arg1: 0x7f4a97e0a0c0}, traceba第四步定位从未释放的分配内存泄漏有了结构化事件流就可以回答一系列问题。例如下面的脚本会找出从未被 free 的分配并输出对应调用栈与悬挂dangling分配计数# count_tracebacks.py Find tracebacks of allocations with no corresponding free import sys import json from collections import defaultdict allocated dict() for line in sys.stdin: line line.rstrip(\n) data json.loads(line) if data[event] probe_libarrow:je_arrow_mallocx__return: address data[params][arg1] allocated[address] data[traceback] elif data[event] probe_libarrow:je_arrow_rallocx: address data[params][ptr] del allocated[address] elif data[event] probe_libarrow:je_arrow_rallocx__return: address data[params][arg1] allocated[address] data[traceback] elif data[event] probe_libarrow:je_arrow_dallocx: address data[params][ptr] if address in allocated: del allocated[address] elif data[event] probe_libarrow:mi_malloc_aligned__return: address data[params][arg1] allocated[address] data[traceback] elif data[event] probe_libarrow:mi_realloc_aligned: address data[params][p] del allocated[address] elif data[event] probe_libarrow:mi_realloc_aligned__return: address data[params][arg1] allocated[address] data[traceback] elif data[event] probe_libarrow:mi_free: address data[params][p] if address in allocated: del allocated[address] traceback_counts defaultdict(int) for traceback in allocated.values(): traceback_counts[traceback] 1 for traceback, count in sorted(traceback_counts.items(), keylambda x: -x[1]): print(Num of dangling allocations:, count) print(traceback)运行方式与输出示例$ cat processed_events.jsonl | python3 /arrow/count_tracebacks.py Num of dangling allocations: 1 7fc945e5cfd2 arrow::(anonymous namespace)::JemallocAllocator::ReallocateAligned0x13b (/build/cpp/debug/libarrow.so.700.0.0) 7fc945e5fe4f arrow::BaseMemoryPoolImplarrow::(anonymous namespace)::JemallocAllocator::Reallocate0x93 (/build/cpp/debug/libarrow.so.700.0.0) 7fc945e618f7 arrow::PoolBuffer::Resize0xed (/build/cpp/debug/libarrow.so.700.0.0) 55a38b163859 arrow::BufferBuilder::Resize0x12d (/build/cpp/debug/arrow-array-test) 55a38b163bbe arrow::BufferBuilder::Finish0x48 (/build/cpp/debug/arrow-array-test) ...可以看到栈帧能精确还原BufferBuilder::Resize - Finish - TypedBufferBuilder - NumericBuilder的完整调用链从而定位泄漏究竟发生在哪条构建路径上。这套方法论的价值在于不需要给代码打补丁、不需要改构建方式仅凭perf probe 事件解析即可在真实测试/运行负载上完成分配热点与泄漏分析。小结与延伸阅读围绕 Arrow C 的内存体系本文梳理了四层能力Buffer无类型内存容器提供 size/data/mutable_data 快速访问、零拷贝切片、64 字节对齐分配与 BufferBuilder/TypedBufferBuilder 增量构建MemoryPool统一分配出口默认按 jemalloc → mimalloc → system 顺序选择Apple 平台 mimalloc 优先支持ARROW_DEFAULT_MEMORY_POOL环境变量覆盖并提供arrow::stl::allocator与STLMemoryPool完成与 STL 的双向集成Device/MemoryManager用is_cpu()、Buffer::View/ViewOrCopy、GetReader/GetWriter实现设备无关编程Memory Profiling基于perf probeperf record的零改动分配剖析配合事件解析脚本可定位分配热点与未释放的悬挂分配。如需查看完整的类/函数签名可参阅 Memory management API reference内存对齐与 padding 规范的底层细节见 Arrow 内存布局规范Buffer 如何被数组消费的完整链条见 Array 指南。源码方面所有核心实现均可在 cpp/src/arrow 目录下找到相关单元测试位于 buffer_test.cc 与 memory_pool_test.cc是理解边界行为的最佳补充材料。【免费下载链接】arrowApache Arrow is a multi-language toolbox for accelerated data interchange and in-memory processing项目地址: https://gitcode.com/gh_mirrors/arrow13/arrow创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表