在 AI-Infra 系统中,Python 通常不是执行密集计算的主体。模型推理、张量运算和部分数据处理,往往由 C、C++、CUDA 或其他原生运行时完成。

Python 更多承担以下职责:

  • 组织请求、任务和配置对象;
  • 管理数据结构与生命周期;
  • 调用 NumPy、PyTorch 等原生库;
  • 连接 CPU 内存、GPU 显存和外部服务;
  • 协调模型、缓存、队列及资源句柄。

因此,Python 性能问题不一定表现为某段 Python 代码“计算得很慢”。很多时候,真正的问题来自:

  • 创建了过多 Python 对象;
  • 临时对象存活时间过长;
  • 容器或缓存无限增长;
  • 不小心进行了复制;
  • Python 对象与原生数据之间发生了隐式转换;
  • CPU 内存、原生内存和 GPU 显存的所有权边界不清晰。

本文聚焦 Python 工程师在 AI-Infra 场景下必须掌握的内存知识,包括对象模型、内存分配、复制语义、垃圾回收、缓存生命周期,以及 Python 与原生运行时之间的内存边界。

本文不重点讨论线程、进程、异步调度、动态批处理或 CPU 计算并行化。这些内容属于并发模型和任务协作的范畴。

一、为什么 AI-Infra 需要理解 Python 内存

一个典型的推理请求可能经历如下过程:

网络请求
  → Python 请求对象
  → 输入校验与预处理
  → NumPy 数组或 PyTorch 张量
  → 原生推理运行时
  → 输出张量
  → Python 结果对象
  → JSON 或其他协议响应

在这条链路中,Python 可能参与了多次对象创建、格式转换和生命周期管理。

例如,下面的代码看起来只是进行一次简单的数据转换:

payload = request.json()
values = payload["values"]

array = np.array(values, dtype=np.float32)
tensor = torch.from_numpy(array)
result = tensor.tolist()

但它背后可能发生了以下事情:

  1. JSON 解析创建大量 Python dictlistfloat 对象;
  2. np.array() 将 Python 对象转换为连续的 NumPy 内存;
  3. torch.from_numpy() 创建张量描述对象,并可能共享 NumPy 的底层存储;
  4. 推理过程可能将数据复制到 GPU;
  5. tolist() 又将底层数值转换回大量 Python 标量和列表对象;
  6. JSON 序列化再次遍历这些对象。

如果请求数据规模较大,或者并发请求较多,内存峰值可能远高于输入数据本身。

因此,内存优化的第一步不是立即修改代码,而是回答以下问题:

  • 当前对象由谁创建?
  • 对象的底层数据存在哪里?
  • 是否发生了复制?
  • 对象会存活多久?
  • 内存由 Python 管理,还是由原生库管理?
  • 释放 Python 引用后,底层内存是否真的被归还?

二、Python 对象模型与内存开销

1. 名称、引用与对象

Python 变量本质上不是一个固定大小的内存槽位,而是指向对象的名称绑定。

a = [1, 2, 3]
b = a

b.append(4)

print(a)
# [1, 2, 3, 4]

这里并没有创建两个列表。ab 引用了同一个列表对象。

可以使用 id() 验证对象身份:

print(id(a) == id(b))
# True

这意味着,理解 Python 内存时不能只看变量名,还要关注:

  • 有多少个引用指向同一个对象;
  • 对象是否可变;
  • 引用是否被容器、闭包、任务或缓存长期持有。

值得注意的是,CPython 对部分常用对象做了缓存优化:小整数(-5 到 256)在解释器启动时预先创建并全局共享,短字符串也可能被驻留(intern)。这意味着对同一个小整数的多次引用实际指向同一个对象:

a = 256
b = 256
print(a is b)
# True

a = 257
b = 257
print(a is b)
# False(不在缓存范围内,可能创建了不同的对象)

这不影响正确性(应使用 == 而非 is 比较值),但有助于理解 id() 结果和小对象的内存行为。

2. Python 标量不是裸数据

在底层语言中,一个整数通常可以直接表示为固定大小的机器数据。但在 Python 中,整数是完整的对象:

value = 42

这个对象除了保存数值本身,还需要保存类型信息以及解释器管理所需的元数据。

因此,大量 Python 标量组成的列表会产生较高的额外开销:

values = [float(i) for i in range(1_000_000)]

这个列表包含:

  • 列表自身的指针数组;
  • 大量独立的 Python float 对象;
  • 每个对象的类型和引用计数信息。

如果数据本质上是规则的数值集合,使用 NumPy 数组通常更适合:

import numpy as np

values = np.arange(1_000_000, dtype=np.float32)

NumPy 数组通常将数据存储在连续的原生内存中,避免为每个数值创建独立的 Python 对象。

这并不意味着“所有列表都应该改成 NumPy 数组”。如果数据包含复杂的异构结构,例如请求元数据、配置项或插件描述,Python 容器仍然是合理选择。关键在于区分:

  • 适合 Python 对象表达的数据;
  • 适合连续数值内存表达的数据。

3. 容器自身也有额外开销

Python 容器通常保存的是对象引用,而不是对象内联数据。

items = [1, 2, 3]

列表内部保存的是指向三个整数对象的引用。

对于字典而言,除了键和值对象本身,还需要维护哈希表结构:

metadata = {
    "request_id": "abc",
    "model": "embedding",
    "priority": 1,
}

在请求量较大、对象生命周期较短的系统中,大量创建小型字典和列表会带来:

  • 更高的内存占用;
  • 更多的分配与释放操作;
  • 更大的垃圾回收压力;
  • 更高的对象遍历成本。

如果对象结构固定,可以考虑使用更紧凑的表示。

4. __slots__ 减少实例字典

普通类实例通常拥有一个 __dict__,用于保存实例属性:

class RequestContext:
    def __init__(self, request_id, model_name, deadline):
        self.request_id = request_id
        self.model_name = model_name
        self.deadline = deadline

当系统创建大量此类对象时,每个实例的属性字典都会产生额外开销。

可以使用 __slots__

class RequestContext:
    __slots__ = ("request_id", "model_name", "deadline")

    def __init__(self, request_id, model_name, deadline):
        self.request_id = request_id
        self.model_name = model_name
        self.deadline = deadline

__slots__ 的主要作用是:

  • 限定允许使用的属性;
  • 通常减少实例内存占用;
  • 避免为每个实例创建独立的 __dict__

但它并不是无条件的优化。使用时需要注意:

  • 不能随意添加未声明的属性;
  • 某些依赖 __dict__ 的工具可能无法正常工作;
  • 继承关系复杂时需要谨慎设计;
  • 如果对象数量很少,节省的内存可能没有实际意义。

对于大量创建、结构稳定、生命周期较短的请求元数据或任务节点,__slots__ 比较适合。

关于 __slots__ 与类对象模型的详细讨论,参见《Python 核心机制与工程基础》

5. 使用数据类表达结构化对象

对于结构化数据,可以使用 dataclass 提高可读性:

from dataclasses import dataclass

@dataclass(slots=True)
class RequestContext:
    request_id: str
    model_name: str
    deadline: float

slots=True 可以让数据类使用槽位存储属性。

需要注意的是,数据类主要解决的是结构表达和代码维护问题。是否能够明显降低内存占用,仍然取决于对象数量、字段类型和实际生命周期。

关于 dataclass 的完整用法和与 Pydantic BaseModel 的对比,参见《Python 核心机制与工程基础》

三、复制、视图与对象共享

1. 赋值不是复制

config = {"timeout": 1.0}
backup = config

backup["timeout"] = 2.0

print(config["timeout"])
# 2.0

backup = config 只增加了一个引用,并没有创建新的字典。

如果确实需要复制,可以使用浅拷贝:

backup = config.copy()

此时修改顶层键值不会影响原字典:

backup["timeout"] = 2.0
print(config["timeout"])
# 1.0

但浅拷贝只复制最外层容器:

config = {
    "limits": {
        "max_tokens": 1024,
    }
}

backup = config.copy()
backup["limits"]["max_tokens"] = 2048

print(config["limits"]["max_tokens"])
# 2048

嵌套字典仍然是共享的。

2. 深拷贝可能代价很高

import copy

backup = copy.deepcopy(config)

深拷贝会递归复制嵌套对象,可能导致:

  • 更高的 CPU 开销;
  • 更高的瞬时内存峰值;
  • 复制不必要的数据;
  • 对包含锁、文件句柄或原生资源的对象产生问题。

在请求路径中,不应为了“安全”而盲目使用 deepcopy()。更好的方式通常是:

  • 明确哪些字段需要复制;
  • 只复制会被修改的部分;
  • 使用不可变对象;
  • 构造新的轻量对象;
  • 通过类型和接口限制修改范围。

例如:

new_limits = {
    **config["limits"],
    "max_tokens": 2048,
}

new_config = {
    **config,
    "limits": new_limits,
}

这种方式虽然也会创建对象,但复制范围更加明确。

3. 切片可能创建副本

对于列表,切片会创建新的列表:

items = list(range(1_000_000))
part = items[:500_000]

part 是一个新列表,包含新的引用数组。虽然列表中的元素对象可能仍然共享,但顶层容器已经被复制。

如果只需要遍历一段数据,可以考虑使用迭代器:

from itertools import islice

part = islice(items, 500_000)

islice() 不会立即创建一个新的大列表,而是按需产生元素。

但需要注意,迭代器也可能持有原始对象的引用。如果原始列表很大,迭代器的生命周期同样不应过长。

4. 视图不等于副本

对于 NumPy 数组,某些切片操作会产生视图:

import numpy as np

array = np.arange(10)
view = array[2:6]

view[:] = 100

print(array)
# [  0   1 100 100 100 100   6   7   8   9]

viewarray 共享底层内存。

如果需要独立副本,需要显式调用:

copy_array = array[2:6].copy()

视图能够减少内存复制,但也带来生命周期问题:一个很小的视图可能继续持有一个很大的底层数组。

large_array = np.zeros(100_000_000, dtype=np.float32)
small_view = large_array[:10]

即使 small_view 只使用很少的数据,它仍然可能让 large_array 的底层内存保持存活。

如果只需要保留少量数据,可以显式复制:

small_copy = large_array[:10].copy()

因此,视图优化需要同时考虑:

  • 是否避免了不必要的复制;
  • 视图是否会延长大对象生命周期;
  • 共享数据是否可能被意外修改。

四、缓冲区协议与底层内存共享

1. bytesbytearraymemoryview

处理网络数据、文件内容或二进制输入时,常见对象包括:

  • bytes:不可变字节序列;
  • bytearray:可变字节序列;
  • memoryview:对已有缓冲区的视图。
data = bytearray(b"abcdef")
view = memoryview(data)

view[0] = ord("X")

print(data)
# bytearray(b'Xbcdef')

memoryview 本身不需要复制整个底层缓冲区,可以让函数访问已有内存的一部分。

例如:

def parse_header(buffer: memoryview) -> memoryview:
    return buffer[:16]

data = bytearray(1024)
header = parse_header(memoryview(data))

这类方式适合高频处理二进制数据的场景,例如:

  • 网络协议解析;
  • 文件分片;
  • 二进制序列化;
  • 音视频或图像缓冲区处理;
  • 推理输入的预处理。

在处理固定格式的二进制协议时,struct 模块可以与 memoryview 配合使用,在不复制数据的前提下解析字段:

import struct

data = bytearray(b"\x01\x00\x00\x00\x00\x00\x80\x3f")  # int32(1) + float32(1.0)
view = memoryview(data)
version, score = struct.unpack_from("<if", view)

2. 零拷贝不是绝对概念

“零拷贝”通常意味着多个组件共享同一块底层内存,但它必须满足具体条件:

  • 数据格式兼容;
  • dtype 兼容;
  • 内存布局兼容;
  • 生命周期足够长;
  • 接收方支持外部缓冲区;
  • 不需要跨设备复制。

以下代码可能共享底层内存:

import numpy as np
import torch

array = np.zeros(8, dtype=np.float32)
tensor = torch.from_numpy(array)

tensor[0] = 1.0

print(array[0])
# 1.0

但如果数据类型或接口不兼容,转换可能发生复制:

array = np.zeros(8, dtype=np.float64)
tensor = torch.tensor(array, dtype=torch.float32)

这里通常需要创建新的 float32 存储,因为原数组是 float64

因此,不能仅根据代码表面判断是否发生了复制。应当结合以下因素验证:

  • 数据指针是否相同;
  • dtype 是否一致;
  • shape 和 stride 是否兼容;
  • 数组或张量是否拥有独立存储;
  • 转换接口的具体语义。

五、数据布局与隐式复制

1. 连续内存与非连续内存

底层数值库通常更喜欢连续内存。对数组转置或跨步切片后,得到的对象可能是非连续的:

array = np.arange(12, dtype=np.float32).reshape(3, 4)
transposed = array.T

print(array.flags["C_CONTIGUOUS"])
# True

print(transposed.flags["C_CONTIGUOUS"])
# False

非连续数组不一定有问题。很多操作可以直接处理 stride 信息。但某些底层接口要求连续内存,此时可能自动创建一个连续副本。

可以显式检查:

if not transposed.flags["C_CONTIGUOUS"]:
    transposed = np.ascontiguousarray(transposed)

显式转换的价值不只是“可能更快”,还在于:

  • 复制发生的位置更明确;
  • 更容易统计复制成本;
  • 避免在不可控的底层调用中发生隐式复制;
  • 便于在性能关键路径外完成准备工作。

2. dtype 转换也可能产生新内存

array = np.zeros(1024, dtype=np.float64)
float32_array = array.astype(np.float32)

astype() 通常会创建新的数组,因为每个元素都需要重新编码。

如果 dtype 已经符合要求,可以使用:

float32_array = array.astype(np.float32, copy=False)

copy=False 只是表达“尽量避免复制”,并不保证永远不复制。如果类型转换确实无法通过视图完成,底层仍可能创建副本。

类似地,np.asarray() 通常倾向于避免不必要的复制:

array = np.asarray(source, dtype=np.float32)

但当输入类型或 dtype 不兼容时,仍然可能发生转换。

3. Python 列表与数组之间的边界

以下代码会将 Python 列表转换为 NumPy 数组:

values = [1.0, 2.0, 3.0]
array = np.asarray(values, dtype=np.float32)

这个转换是必要的,因为列表中的 Python 对象并不是连续的 float32 内存。

相反,以下操作会将连续数组重新转换成大量 Python 对象:

values = array.tolist()

在推理服务中,tolist() 很容易成为内存和延迟问题的来源,尤其是输出规模较大时。

如果协议或下游接口允许,应优先使用:

  • NumPy 数组;
  • PyTorch 张量;
  • 二进制序列化格式;
  • 支持数组的结构化协议。

如果最终必须返回 JSON,就应明确接受这一步转换的成本,并避免在链路中重复转换。

六、Python 与原生运行时的内存边界

1. Python 内存不等于进程内存

一个进程的内存通常来自多个来源:

  • Python 对象;
  • Python 解释器和标准库;
  • NumPy、PyTorch 等扩展;
  • C/C++ 分配的原生内存;
  • 内存映射文件;
  • 线程栈;
  • GPU 驱动和设备缓存。

因此,tracemalloc 只能观察 Python 层的一部分分配,不能解释所有 RSS 增长。

例如:

import tracemalloc

tracemalloc.start()

# 执行一段业务逻辑

current, peak = tracemalloc.get_traced_memory()
print(f"current={current}, peak={peak}")

如果 RSS 持续增长,但 tracemalloc 中没有对应增长,问题可能发生在:

  • C/C++ 扩展;
  • 深度学习框架;
  • GPU 分配器;
  • 内存映射;
  • 系统级缓存。

2. CPU 内存与 GPU 显存是不同的资源

在深度学习系统中,至少需要区分:

  1. Python 对象占用的内存;
  2. 进程的 CPU 内存;
  3. 原生库管理的 CPU 缓冲区;
  4. GPU 显存;
  5. GPU 框架的缓存分配器保留空间。

释放一个 Python 引用:

del tensor

只表示当前 Python 作用域不再持有这个引用。它不一定意味着:

  • 底层内存立即归还操作系统;
  • GPU 缓存立即下降;
  • 其他对象不再共享该存储;
  • 异步设备操作已经完成。

如果还有其他引用存在,底层数据自然不会释放:

outputs = model(inputs)
saved = outputs
del outputs

此时 saved 仍然持有输出对象。

3. 谨慎使用 .cpu().numpy().tolist()

这些操作可能改变数据所在的位置、表示形式或所有权关系:

cpu_tensor = gpu_tensor.cpu()
array = cpu_tensor.numpy()
values = array.tolist()

这条链路可能包含:

  • GPU 到 CPU 的数据传输;
  • 张量与 NumPy 之间的共享或复制;
  • NumPy 数值到 Python 标量的对象转换。

在调试代码中,下面这种写法尤其需要注意:

print(gpu_tensor.cpu().tolist())

它可能为了打印少量信息而触发完整的数据传输和对象构造。

更稳妥的方式是:

print(gpu_tensor.shape)
print(gpu_tensor.device)
print(gpu_tensor.dtype)

如果只需要查看少量值,应限制范围:

print(gpu_tensor.flatten()[:8].cpu().tolist())

七、引用计数、垃圾回收与内存分配器

前面几章讨论了 Python 对象的内存开销、复制语义和原生内存边界。但一个关键问题还没有回答:Python 是如何决定何时释放一个对象的?

1. 引用计数:CPython 的基本回收机制

CPython 中,每个对象都有一个引用计数器(ob_refcnt)。当引用计数降为零时,对象立即释放:

import sys

a = [1, 2, 3]
print(sys.getrefcount(a))
# 2(a 本身 + getrefcount 的参数)

b = a
print(sys.getrefcount(a))
# 3

del b
print(sys.getrefcount(a))
# 2

引用计数的优点是确定性释放——对象不再被引用时立即回收,不需要等待 GC 轮次。

但引用计数无法处理循环引用:

class Node:
    def __init__(self):
        self.ref = None

a = Node()
b = Node()
a.ref = b
b.ref = a

del a
del b
# 两个 Node 对象的引用计数都不为零,但已经不可达

2. 循环垃圾回收器

为了处理循环引用,CPython 引入了分代循环垃圾回收器。它将对象分为三代:

  • Generation 0:新创建的对象,回收频率最高;
  • Generation 1:经历一次 Gen 0 回收后存活的对象;
  • Generation 2:经历一次 Gen 1 回收后存活的对象,回收频率最低。

可以查看和调整各代的阈值:

import gc

print(gc.get_threshold())
# (700, 10, 10)
# 含义:Gen 0 中分配数 - 释放数达到 700 时触发 Gen 0 回收;
# Gen 0 回收 10 次后触发 Gen 1 回收;
# Gen 1 回收 10 次后触发 Gen 2 回收。

在 AI-Infra 服务中,需要注意以下几点:

  • 循环 GC 需要遍历容器对象(listdict、自定义类实例等),这会带来暂停;
  • 如果系统中存活对象数量很大,Gen 2 回收可能导致明显的延迟尖刺;
  • 某些高性能框架会禁用循环 GC(gc.disable()),依赖引用计数和手动管理来避免暂停。

3. __del__ 终结器的陷阱

__del__ 是 Python 的终结器方法,在对象被回收前调用。它看起来适合做资源清理,但实际使用中有严重的陷阱:

class Resource:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"releasing {self.name}")

问题在于:

  • 阻碍循环回收:在 Python 3.4 之前,带有 __del__ 的循环引用对象无法被 GC 回收(放入 gc.garbage)。Python 3.4+ 通过 PEP 442 改善了这一点,但 __del__ 的调用顺序仍然不确定;
  • 调用时机不可控__del__ 可能在任意时刻被调用,甚至可能在解释器关闭时被调用,此时模块级变量可能已经被清理;
  • 对象复活:如果 __del__ 中重新创建了对该对象的引用,对象不会被释放。

更安全的替代方案:

import weakref

class Resource:
    def __init__(self, name):
        self.name = name
        self._finalizer = weakref.finalize(self, Resource._cleanup, name)

    @staticmethod
    def _cleanup(name):
        print(f"releasing {name}")

weakref.finalize() 不阻碍垃圾回收,且可以显式调用 _finalizer() 提前清理。

对于 AI-Infra 场景中的资源管理(GPU 句柄、网络连接、临时文件),优先使用上下文管理器(with 语句),而不是依赖 __del__

4. pymalloc:CPython 的内存分配器

CPython 使用名为 pymalloc 的专用分配器来管理小对象(≤ 512 bytes)。它的结构是:

Arena (256 KB, 向 OS 申请)
  └── Pool (4 KB, 按 size class 划分)
        └── Block (8, 16, 24, ..., 512 bytes)

关键行为:

  • 小对象由 pymalloc 管理,大对象直接走系统 malloc
  • Pool 释放后归还 Arena,但 Arena 只有在所有 Pool 都释放后才归还 OS
  • 这意味着:即使大量 Python 小对象被释放,进程 RSS 可能仍然不下降。

这是实际排查中最容易引起困惑的现象:

del 大量 Python 对象
  → 引用计数归零,对象释放
  → pymalloc 回收 block/pool
  → 但 arena 中仍有少量存活对象
  → arena 不释放 → RSS 不降
  → 看起来像"内存泄漏",实际是分配器保留

因此,在判断是否发生内存泄漏时,需要区分”分配器保留”和”对象仍被引用”。tracemalloc 可以帮助定位后者,但对分配器保留行为无能为力。

如果确实需要将内存归还 OS(例如在模型切换后释放大量临时数据),可以考虑 gc.collect() 后观察 RSS 变化,但不应期望 RSS 每次都下降到初始水平。

八、缓存、引用与对象生命周期

1. 无界缓存是常见的内存问题

下面的代码会让缓存持续增长:

cache = {}

def remember(key, value):
    cache[key] = value

如果 key 持续变化,且没有淘汰策略,进程内存最终会不断上升。

更合理的方式是设置边界:

from functools import lru_cache

@lru_cache(maxsize=1024)
def load_config(model_name: str):
    return build_config(model_name)

lru_cache 也不是自动解决方案:

  • maxsize 仍然需要合理设置;
  • 参数必须可哈希;
  • 缓存值会被强引用;
  • 大对象缓存可能显著增加内存峰值;
  • 配置变化后需要考虑失效机制。

缓存设计必须同时考虑:

  • 命中率;
  • 单项大小;
  • 总容量;
  • 生命周期;
  • 失效条件;
  • 并发访问;
  • 更新策略。

2. 闭包和回调可能延长对象生命周期

def create_handler(large_model):
    def handler(request):
        return large_model.predict(request)

    return handler

handler 闭包持有 large_model 的引用。只要 handler 存在,模型对象就不会被释放。

这通常是有意的,但类似机制也可能造成意外保留:

  • 任务回调捕获完整请求对象;
  • 日志函数闭包捕获大数据;
  • 异常对象保留局部变量;
  • 全局列表保存历史任务;
  • 调试代码保存完整输出。

在高并发服务中,应避免让短生命周期回调捕获不必要的大对象。

关于闭包的工作机制和常见陷阱(如延迟绑定),参见《Python 核心机制与工程基础》

3. 任务对象和异常对象也可能持有引用

异步任务、Future、回调和异常对象可能间接持有:

  • 请求上下文;
  • 输入张量;
  • 输出结果;
  • 局部变量;
  • traceback。

因此,任务完成后应及时清理不再需要的引用,避免将任务对象无限保存。

关于异步任务、Future 和回调的生命周期管理,参见《Python 并发、异步与任务协作》

这并不意味着要到处手动调用 gc.collect()。频繁强制垃圾回收可能增加延迟,甚至降低吞吐。更重要的是:

  • 缩短对象生命周期;
  • 避免无界引用集合;
  • 清理完成任务;
  • 限制缓存;
  • 解除不必要的闭包和回调引用。

4. 弱引用适合非所有权关系

当一个容器只需要观察对象,而不应该延长对象生命周期时,可以考虑 weakref

import weakref

class Model:
    pass

model = Model()
registry = weakref.WeakValueDictionary()
registry["default"] = model

del model

当对象没有其他强引用时,弱引用容器中的条目也会自动消失。

弱引用适合:

  • 非所有权注册表;
  • 对象索引;
  • 调试辅助结构;
  • 避免缓存反向持有对象。

但如果业务逻辑要求对象必须保持存活,就不应使用弱引用。

九、常见内存问题的排查方法

1. 先区分三类增长

看到进程内存上升时,首先要区分:

  1. 仍然被业务对象引用
  2. 分配器保留了内存,但对象已经释放
  3. 原生库或设备运行时持有内存

它们的处理方法不同。

  • 第一类需要查找引用链和生命周期;
  • 第二类需要理解分配器行为;
  • 第三类需要使用对应原生库的诊断工具。

不能仅凭 RSS 上升就判断发生了内存泄漏。

2. 使用 sys.getsizeof() 时要注意边界

import sys

value = [1, 2, 3]
print(sys.getsizeof(value))

sys.getsizeof() 通常只返回对象自身的浅层大小,不会递归计算嵌套对象:

items = [[1, 2, 3], [4, 5, 6]]
print(sys.getsizeof(items))

这个结果不包含嵌套列表和其中元素的完整大小。

因此,它适合做局部观察,不适合直接作为复杂对象的总内存统计工具。

3. 使用 tracemalloc 定位 Python 分配

import tracemalloc

tracemalloc.start()

snapshot_before = tracemalloc.take_snapshot()

# 执行可能产生内存增长的操作

snapshot_after = tracemalloc.take_snapshot()

for statistic in snapshot_after.compare_to(
    snapshot_before,
    "lineno",
)[:10]:
    print(statistic)

它适合定位:

  • 哪些 Python 代码行产生了较多分配;
  • 哪些分配在操作前后持续增长;
  • 哪些容器或对象可能被长期保留。

但它不能完整反映:

  • NumPy 底层缓冲区;
  • PyTorch 原生分配;
  • GPU 显存;
  • 所有 C 扩展分配。

4. 检查对象是否仍被引用

可以使用垃圾回收模块观察对象:

import gc

unreachable = gc.collect()
print("unreachable objects:", unreachable)

如果需要追踪某个对象为什么没有被释放,可以使用 gc.get_referrers() 查看引用链:

import gc

obj = SomeLargeObject()
# ... 经过一系列操作后,怀疑 obj 被意外持有

referrers = gc.get_referrers(obj)
for r in referrers:
    print(type(r), id(r))

对于更复杂的引用图分析,objgraph 库可以生成可视化的引用关系图:

import objgraph

# 查看增长最快的对象类型
objgraph.show_growth(limit=10)

# 查看某个对象的引用链(需要安装 graphviz)
objgraph.show_backrefs(obj, max_depth=5, filename="refs.png")

也可以通过代码设计减少不必要的引用:

  • 不保存完整请求;
  • 只保存请求 ID;
  • 不把输出张量挂在长期存活的任务对象上;
  • 完成后清理临时缓存;
  • 避免全局列表记录全部历史数据。

垃圾回收工具适合辅助诊断,但不应替代生命周期设计。

5. 观察进程级内存

进程 RSS 能够反映操作系统视角下的常驻内存,但不能直接告诉你增长来自哪里。

实际排查通常需要结合:

  • Python 分配追踪;
  • 进程 RSS;
  • 原生库统计;
  • GPU 内存统计;
  • 请求数量和缓存大小;
  • 长时间压力测试。

只有将这些指标放在同一时间线上,才能判断问题属于 Python 对象、原生内存还是设备缓存。

6. GPU 显存诊断

对于使用 PyTorch 的 AI-Infra 服务,GPU 显存问题需要使用专门的工具:

import torch

# 当前已分配的 GPU 显存
print(torch.cuda.memory_allocated())

# 历史峰值分配量
print(torch.cuda.max_memory_allocated())

# PyTorch 缓存分配器保留的总显存(可能大于已分配量)
print(torch.cuda.memory_reserved())

# 完整的显存统计摘要
print(torch.cuda.memory_summary())

需要注意的是,torch.cuda.memory_reserved() 通常大于 memory_allocated(),因为 PyTorch 的缓存分配器会保留已释放的显存块以供复用。这不是泄漏,而是分配器的正常行为。

如果需要从命令行监控,可以使用:

nvidia-smi --query-gpu=memory.used,memory.free --format=csv -l 1

十、工程实践中的设计原则

1. 让数据表示匹配数据性质

  • 结构化元数据使用 Python 对象;
  • 大规模连续数值使用 NumPy 或张量;
  • 二进制数据尽量使用缓冲区接口;
  • 避免在 Python 列表和连续数组之间来回转换。

2. 明确复制边界

对以下操作保持敏感:

  • list(...)
  • dict(...)
  • 列表切片;
  • copy.copy()
  • copy.deepcopy()
  • np.array()
  • astype()
  • .clone()
  • .cpu()
  • .numpy()
  • .tolist()
  • 序列化与反序列化。

每次操作都应思考:

这里是共享数据、创建视图,还是产生了完整副本?

3. 控制对象生命周期

  • 请求对象只存活在请求范围内;
  • 不把大对象挂到全局状态;
  • 完成任务后移除任务引用;
  • 限制缓存容量;
  • 避免闭包捕获无关对象;
  • 不为了日志保存完整输入和输出。

4. 不要把所有内存问题归因于 Python

当内存增长时,应分别检查:

  • Python 对象;
  • C/C++ 扩展;
  • NumPy 缓冲区;
  • 深度学习框架缓存;
  • GPU 显存;
  • 操作系统分配器。

不同层的内存需要使用不同的工具和指标诊断。

5. 优先降低峰值,而不只是降低平均占用

AI-Infra 服务经常受到峰值内存限制。即使平均内存占用正常,以下情况仍可能导致 OOM:

  • 多个请求同时创建大对象;
  • 输入和输出短时间同时存活;
  • 转换过程中同时存在原始数据和副本;
  • 模型初始化期间存在临时权重;
  • 缓存更新时产生新的大对象。

因此,应重点关注:

  • 峰值内存;
  • 并发请求下的最坏情况;
  • 转换过程中的临时副本;
  • 失败和超时路径上的资源释放。

十一、一个简单的内存审查案例

假设服务中存在如下代码:

def predict(payload):
    values = payload["values"]

    array = np.array(values, dtype=np.float32)
    tensor = torch.tensor(array, device="cuda")

    output = model(tensor)
    return output.cpu().numpy().tolist()

这段代码可能存在以下问题:

  1. payload 中的 Python 列表本身占用较多对象内存;
  2. np.array() 创建 NumPy 数组;
  3. torch.tensor() 通常创建新的张量存储;
  4. 张量被复制到 GPU;
  5. output.cpu() 将结果复制回 CPU;
  6. .numpy() 创建或暴露 NumPy 表示;
  7. .tolist() 创建大量 Python 标量和列表对象。

这并不意味着每一步都可以直接删除,因为接口边界可能确实需要这些转换。但可以进行以下审查:

  • 是否可以直接从协议读取更适合的数值格式;
  • 是否可以使用支持共享内存的转换方式;
  • 是否可以避免不必要的 dtype 转换;
  • 是否必须把完整输出转成 Python 列表;
  • 是否可以限制返回结果规模;
  • 是否可以让下游接口直接处理数组或二进制数据;
  • 是否需要同时保留输入和输出的多个表示。

优化后的代码可能类似:

def predict(array: np.ndarray):
    if array.dtype != np.float32:
        array = array.astype(np.float32, copy=False)

    if not array.flags["C_CONTIGUOUS"]:
        array = np.ascontiguousarray(array)

    tensor = torch.from_numpy(array).to(device="cuda")
    output = model(tensor)

    # 如果下游可以直接消费张量(如内部 pipeline 或 gRPC),直接返回
    return output
    # 如果必须返回给 HTTP 客户端,在明确的边界做一次转换
    # return output.cpu().numpy().tolist()

这个版本并不适用于所有 API,但它展示了一个重要原则:

尽量在明确的边界完成必要转换,并避免在链路中反复改变数据表示。

十二、内存优化检查清单

在优化一条 Python AI-Infra 请求路径时,可以依次检查:

1. 对象层面

  • 是否创建了大量不必要的 Python 小对象?
  • 是否可以使用数组或张量表示连续数值?
  • 是否需要使用 __slots__
  • 是否存在大量临时字典、列表或元组?

2. 复制层面

  • 赋值是否被误认为复制?
  • 是否发生了浅拷贝或深拷贝?
  • 切片是否产生了新容器?
  • NumPy 或张量操作是否创建了新存储?
  • 是否存在重复的序列化和反序列化?

3. 数据层面

  • dtype 是否发生转换?
  • 数组是否连续?
  • 是否可以使用视图?
  • 视图是否意外延长了大对象生命周期?
  • 是否可以复用已有缓冲区?

4. 生命周期层面

  • 请求结束后,大对象是否仍被引用?
  • 任务、Future 或回调是否保存了请求上下文?
  • 缓存是否有容量和失效策略?
  • 全局容器是否持续增长?
  • 异常和日志对象是否保留了大对象?

5. 运行时层面

  • Python 内存是否与原生内存混淆?
  • CPU 内存是否与 GPU 显存混淆?
  • 释放引用后,底层分配器是否仍保留内存?
  • 是否使用了正确层级的监控和诊断工具?

附:Java 与 Python 内存管理对照

维度 Java Python (CPython)
基本回收机制 可达性分析 + 分代 GC(G1/ZGC/Shenandoah) 引用计数 + 分代循环 GC
回收时机 GC 线程异步回收,不确定 引用计数归零时立即回收;循环引用依赖 GC 轮次
值类型 int/long/double 等原语不装箱,栈上分配 一切皆对象,int/float 也是堆对象
数组 int[] 连续原语内存 list 存引用;需 array / NumPy 才连续
堆外内存 ByteBuffer.allocateDirect() / Unsafe memoryview / buffer protocol
终结器 Cleaner / PhantomReference(推荐);finalize() 已废弃 weakref.finalize()(推荐);__del__ 有陷阱
内存分配器 JVM 自管理堆(TLAB + 分代) pymalloc(arena → pool → block)
RSS 不下降 GC 后可归还 OS(G1/ZGC 支持) pymalloc arena 可能不归还 OS
内存分析工具 JVisualVM / MAT / JFR / async-profiler tracemalloc / objgraph / pympler / memray
GPU 内存 不直接涉及(由 native 库管理) 同样由 CUDA runtime / PyTorch 缓存分配器管理
弱引用 WeakReference / WeakHashMap weakref.ref() / WeakValueDictionary
零拷贝 NIO MappedByteBuffer / Netty CompositeByteBuf memoryview / torch.from_numpy() 共享存储

十三、总结

Python 内存管理的核心,不只是调用 del 或手动触发垃圾回收,而是理解以下几个层次:

Python 名称与对象
  → 引用关系
  → 容器与实例开销
  → 复制、视图与共享
  → 内存分配与垃圾回收
  → 缓存和生命周期
  → 原生库与设备内存边界

在 AI-Infra 中,Python 工程师不一定需要自己实现底层内存分配器,但必须能够判断:

  • 哪些对象由 Python 管理;
  • 哪些数据存储在原生缓冲区中;
  • 哪些操作会创建副本;
  • 哪些引用会延长对象生命周期;
  • 哪些内存由框架缓存;
  • 哪些问题属于 Python,哪些问题属于原生运行时或设备运行时。

本文的核心结论是:

Python 内存优化的关键,不是简单地释放变量,而是减少不必要的对象创建和数据复制,明确对象生命周期,并正确理解 Python、原生库、进程和设备之间的内存所有权边界。

对于 AI-Infra 系统而言,可以将这套方法概括为:

用合适的数据结构表达数据
用引用关系理解对象生命周期
用显式边界识别复制行为
用视图和缓冲区减少不必要的分配
用有界缓存控制长期占用
用分层工具定位内存来源

当 Python 专注于组织和管理,而连续数据、底层缓冲区和设备资源由合适的原生运行时负责时,系统才能在保持工程灵活性的同时,避免不必要的内存开销。


×