共计 10688 个字符,预计需要花费 27 分钟才能阅读完成。
春节前后拜读了许啸宇的《TorchDynamo 初探:Python ByteCode 的动静批改》,随后简略总结了一下 TorchDynamo 的执行过程。这期间对 CPython 也多了一些理解。记得之前看到过,oneflow Tensor 是通过 CPython API 导出给 Python 环境的,过后很多细节都不理解,就着这个热乎劲,从新看了一下 Tensor 类型注册相干的内容,顺便相熟一下 CPython 创建对象的过程。
本文中波及 CPython 相干的内容能够参考 Python behind the scenes。
1 Python 中的类型
Python 中一切都是对象。对象的数据类型自身也是对象,比方 float、list 这些,都是对象。这些 类型对象
中,有一个比拟非凡,就是 type
,它是 元类型
(metatype),即类型的类型。以下断言是成立的:
assert(float.__class__ is type)
assert(type.__class__ is type)
这些类型对象在 CPyhton 中的定义如下,它们的 C 类型都是 PyTypeObject,也都能够被视为 PyObject。
- object: PyBaseObject_Type(定义)
- type: PyType_Type(定义)
- float: PyFloat_Type(定义)
- list: PyList_Type(定义)
PyTypeObject 的很多字段(slot)都是函数指针。这些 slot 的不同取值,决定了不同类型的行为。slotdefs 数组定义了 magic method 的 slot。
2 Tensor 类型的注册
one::Tensor 类型注册的代码在 tensor.cpp 中。这里定义了三个 PyTypeObject 对象:
- TensorMetaclass_Type: metatype
- PyTensorObject_Type: oneflow.Tensor
- PyParameterObject_Type: oneflow.Parameter,是 oneflow.Tensor 的子类型。
MakeTensorMetaclass 函数负责初始化 TensorMetaclass_Type 对象。上面这行代码创建对象实体:
auto* heap_type = (PyHeapTypeObject*)PyType_Type.tp_alloc(&PyType_Type, 0);
这行代码有两点须要廓清:
- PyType_Type.tp_alloc 是啥?
- 返回的类型为何是 PyHeapTypeObject?
2.2 CPython 的类型初始化
从字面意义看,tp_alloc 应该会创立一个对象并返回其指针。然而在定义 PyType_Type 时,tp_alloc 的默认值是 0。所有类型在初始化时都会从 base type 拷贝一些 slot。PyType_Type.tp_alloc 的值是从 PyBaseObject_Type.tp_alloc 拷贝而来的,具体函数是 PyType_GenericAlloc(能够参考 Python behind the scenes #6: how Python object system works 中的 Slot inheritance 一节)。
CPython 在启动时初始化类型的具体过程如下:
pycore_init_types ->
_PyTypes_Init ->
INIT_TYPE(PyType_Type) ->
PyType_Ready ->
type_ready(…)
- type_ready_set_bases(…): 设置 type->tp_base = &PyBaseObject_Type,如果 tp_base 为空。
-
type_ready_inherit
-
inherit_slots: 拷贝 slots
- COPYSLOT(tp_alloc): 拷贝单个 slot
-
2.3 tp_alloc 返回的类型为何是 PyHeapTypeObject?
PyType_GenericAlloc 申明的返回的类型是 PyObject *
。不过在计算对象的 size 时,不会小于 tp_basicsize,PyType_Type.tp_basicsize 被初始化为 sizeof(PyHeapTypeObject)
,所以这里返回的对象能够释怀地作为 PyHeapTypeObject 应用。CPython 的 PyType_FromModuleAndSpec 中也是这样解决的。
2.3.1 PyHeapTypeObject 反对更灵便的动静类型
CPython 的内置类型都是间接通过 PyTypeObject 的动态变量定义的,也就是所谓的 Static Types。这些类型对象的 slot 值都能够在编译期取得。
不同于动态定义的 PyTypeObject,PyHeapTypeObject 能够在运行时动静定制类型的行为,比方在框架启动后依据配置文件、内部输出等信息确定类型的 slot 的值。这就是所谓的 Heap Types,类型对象是在堆中存储的。
在 Python 下执行 my_type = type('my_type', (), {})
会进入 type_new_alloc 函数,这里会将 PyHeapTypeObject 的对象类型的 slot 字段地址赋值给指针类型的 slot 字段。而后用户再对 PyHeapTypeObject 的字段赋值,就能够依据内部输出创立动静类型了。间接调用 PyType_Type.tp_call(&PyType_Type, …) 应该能够达到相似成果。
2.4 tp_flags 值的含意
Tensor 类型和元类型设置了三个标签:
- Py_TPFLAGS_DEFAULT: 这应该是所有对象都会设置的一个标签。
- Py_TPFLAGS_BASETYPE: 示意能够作为其它类型的父类。
- Py_TPFLAGS_HEAPTYPE: 从堆中调配的 type object 须要设置这个标记。创立这种类型的对象时,会递增 type object 的援用计数;开释时递加 type object 的援用计数。
2.5 TensorMetaclass_Type 的次要 slot 值
- ob_type: &PyType_Type
- tp_name:
_TensorMeta
- tp_base:
&PyType_Type
- tp_call: TensorMetaCls_call
- tp_dealloc: TensorMetaCls_dealloc
- tp_alloc: PyType_GenericAlloc,继承自 PyBaseObject_Type。
2.6 PyTensorObject_Type 的次要 slot 值
Tensor 的类型对象 PyTensorObject_Type 是通过元类 TensorMetaclass_Type 创立的。PyTensorObject_Type 的局部字段如下:
- ob_type: &TensorMetaclass_Type
- tp_name: Tensor
- tp_init: PyTensorObject_init,调用 functional::_legacy_tensor_ctor(…)
- tp_dealloc: PyTensorObject_dealloc
- tp_alloc: PyType_GenericAlloc,继承自 PyBaseObject_Type。
3 Tensor 对象的创立过程
echo "Tensor()" | python3 -m dis
显示的 Python 子节码如下:
1 0 LOAD_NAME 0 (Tensor)
2 CALL_FUNCTION 0
4 POP_TOP
6 LOAD_CONST 0 (None)
8 RETURN_VALUE
LOAD_NAME 依据名字 Tensor 加载对象,也就是把 &PyTensorObject_Type
放到 Value stack 的栈顶。
CALL_FUNCTION
指令会调用 call_function 函数。这个函数的次要代码如下:
PyObject **pfunc = (*pp_stack) - oparg - 1;
PyObject *func = *pfunc;
PyObject *x, *w;
Py_ssize_t nkwargs = (kwnames == NULL) ? 0 : PyTuple_GET_SIZE(kwnames);
Py_ssize_t nargs = oparg - nkwargs;
PyObject **stack = (*pp_stack) - nargs - nkwargs;
if (trace_info->cframe.use_tracing) {x = trace_call_function(tstate, trace_info, func, stack, nargs, kwnames);
}
else {x = PyObject_Vectorcall(func, stack, nargs | PY_VECTORCALL_ARGUMENTS_OFFSET, kwnames);
}
oparg 是 opcode 对应的参数,在这个例子中就是 LOAD_NAME 对应的 0。
pp_stack 是 value stack 的栈顶指针。所以 func 就是栈顶元素,也就是 &PyTensorObject_Type
。
PyObject_Vectorcall 会调用 _PyObject_VectorcallTstate 持续解决。_PyObject_VectorcallTstate 中的外围代码如下:
func = PyVectorcall_Function(callable);
if (func == NULL) {Py_ssize_t nargs = PyVectorcall_NARGS(nargsf);
return _PyObject_MakeTpCall(tstate, callable, args, nargs, kwnames);
}
res = func(callable, args, nargsf, kwnames);
这里callable
就是 &PyTensorObject_Type
。因为 TensorMetaclass_Type 没有设置 Py_TPFLAGS_HAVE_VECTORCALL,会间接返回 NULL。而后持续调用 _PyObject_MakeTpCall。这个函数的外围代码如下:
ternaryfunc call = Py_TYPE(callable)->tp_call;
PyObject *result = NULL;
result = call(callable, argstuple, kwdict);
这里的 call 是 TensorMetaclass_Type.tp_call,也就是 TensorMetaCls_call。所以 Tensor 对象的创立是由 TensorMetaCls_call 实现的。这个函数间接转发给 PyType_Type.tp_call,也就是 type_call 函数,其中的 type 参数就是 &PyTensorObject_Type
。
3.1 其它设置 Py_TPFLAGS_HAVE_VECTORCALL 的类型
class A:
def func(self):
print("in func")
a = A()
print(a.func.__class__)
print(A.func.__class__)
除了 PyType_Type,以下内置类型也设置了 Py_TPFLAGS_HAVE_VECTORCALL,对相似 f()
这种操作,它们都有本人定制的解决逻辑。
- method_descriptor: 比方
list.append
- function: 比方
A.func
。 - method: 比方
a.func
。所以,如果是函数或办法,PyVectorcall_Function 返回的就不是空指针,会间接调用 func。 - builtin_function_or_method,比方 sum。
3.2 type_call 的执行
type_call 的次要流程如下:
-
obj = type->tp_new(type, args, kwds)
,tp_new 的值是从 PyBaseObject_Type 继承来的 object_new。所以理论执行的是object_new(&PyTensorObject_Type, ...)
-
type->tp_alloc(type, 0)
。理论调用继承自 PyBaseObject_Type 的 PyType_GenericAlloc,即PyType_GenericAlloc(&PyTensorObject_Type, 0)
。- size 是须要调配的内存大小。至多是 tp_basicsize,即 sizeof(PyTensorObject)。
- PyObject_Malloc
memset(obj, '\0', size)
-
_PyObject_Init
- 设置对象类型
- 减少 PyTensorObject_Type 的援用计数。
- 调用 _Py_NewReference 将新 Tensor 对象的援用计数设置为 1。
-
-
type->tp_init(obj, args, kwds)
。type 是&PyTensorObject_Type
,理论调用的是 PyTensorObject_init。- functional::_legacy_tensor_ctor
- PyTensor_wrap,self 是要初始化的对象。
functional::_legacy_tensor_ctor 返回的曾经是一个 PyTensorObject 指针了,预计可能是原来适配 pybind11 的缘故。然而当初数据要绑定到 self 上,所以须要从 temp 拷贝切换到 self。
3.3 heap 对象影响其 type object 的援用计数
每创立一个 flow.Tensor 对象,PyTensorObject_Type 的援用计数会递增,只有不被动删除,类型对象不会主动开释。
import sys
import oneflow as flow
print(sys.getrefcount(flow.Tensor))
a = flow.ones(2, 3)
print(sys.getrefcount(flow.Tensor))
del a
print(sys.getrefcount(flow.Tensor))
通过 PyType_GenericAlloc 创建对象时,如果是 heap type,会递增 type object 的援用计数。对象开释时,也须要递加 type object 的援用计数。
3.4 一般内置类型的创立
Python 内置的 list、float 这些的 ob_type 是 &PyType_Type,它设置了 Py_TPFLAGS_HAVE_VECTORCALL,初始化 tp_vectorcall 为 type_vectorcall,初始化 tp_vectorcall_offset 为 tp_vectorcall 在 struct 中的偏移。所以 PyVectorcall_Function 返回的就是 type_vectorcall,这些内置类型就用这个函数结构对象。以 float 为例,_PyObject_VectorcallTstate 中理论调用的是:
res = type_vectorcall(&PyFloat_Type, args, nargsf, kwnames);
type_vectorcall 中的 metatype、_PyObject_MakeTpCall 中的 callable 是 &PyFloat_Type
。最终仍是通过 type_call 实现对象创立。
4 PyTypeOjbect 几个 slot 的语义
-
tp_call: 对于自定义类型来说,如果
A
是一个 Python 类型,执行A()
,最终会调用它的 元类型的 tp_call,即A->ob_type->tp_call()
。- 这一点和 C++ 是不一样的。能够这样了解:C++ 中,编译器在栈上为对象调配存储,或者通过调用
new
在堆上调配存储;而后调用类的构造函数初始化对象。而 CPython 中,虚拟机调用 metatype 的 tp_call 对立实现存储调配和对象初始化,其中对象初始化由类型 A 实现。 - PyBaseObject_Type.tp_call 是 0,因为不是每个对象都是可调用的。
- 这一点和 C++ 是不一样的。能够这样了解:C++ 中,编译器在栈上为对象调配存储,或者通过调用
-
tp_dealloc: Python 运行时开释一个对象时(例如 oneflow.Tensor),会调用该类型的 tp_dealloc slot(例如 PyTensorObject_dealloc)。调用这个 slot 时,对象实例 self 仍存在,然而援用计数曾经为 0。这个函数须要开释 self 持有的所有其它对象的援用,以及其它相干资源;而后调用类型的 tp_free slot,即
self->ob_type->tp_free(self)
。如果是 heap 类型,还须要递加 type object 的援用计数。- PyBaseObject_Type.tp_dealloc 是 object_dealloc
- PyType_Type.tp_dealloc 是 type_dealloc
-
tp_init: 实现对象的初始化,相当于 class 的
__init__
办法。- PyBaseObject_Type.tp_init 是 object_init
- PyType_Type.tp_init 是 type_init
- tp_alloc: 为类型的实例调配存储空间。创立元类型时调用的是 PyType_Type.tp_alloc,因为 _TensorMeta、float、list 等的 ob_type 都是 PyType_Type。创立 Tensor 类型时调用的是 TensorMetaclass_Type.tp_alloc;创立 Tensor 对象时调用的是 PyTensorObject_Type.tp_alloc。
-
tp_new: 创建对象实例。必须调用 tp_alloc 为对象调配存储空间。tp_new 应尽量少做初始化工作;初始化尽可能在 tp_init 中进行。
- PyBaseObject_Type.tp_new 是 object_new
- PyType_Type.tp_new 是 type_new
-
tp_free: 开释 Python 对象存储。
- tp_dealloc 中须要调用 tp_free,例如 PyTensorObject_dealloc。
- PyBaseObject_Type.tp_free 是 PyObject_Del
- PyType_Type.tp_free 是 PyObject_GC_Del
5 metatype 的作用是什么?
目前看,TensorMetaclass_Type 的次要作用,应该是在 CPython 层面对创立 PyTensorObject 对象的行为进行定制。
元类的作用是创建对象,并进行 Python 层面的初始化,比方设置 ob_type、援用计数等。tensor type 的作用,是进行特定类型的、业务相干的初始化。例如 Tensor 和 Parameter 有各自的初始化函数。
6 防止 PyTensorObject 被频繁创立、开释
在 v0.9.0 中,Tensor 对象初始化时,_legacy_tensor_ctor 曾经返回了 PyTensorObject 对象的指针。为什么还要用 PyTensor_wrap 解决一下呢?
在 v0.8.0 中,以下代码中输入的 id(a.grad)
的值是不固定的:
# test.py
import oneflow as flow
a = flow.ones(2, 3).requires_grad_()
b = flow.ones(2, 3).requires_grad_()
c = a + b
s = c.sum()
s.backward()
print(id(a.grad))
print(id(a.grad))
oneflow 的 issue 9500 对此作了具体探讨。简略的说,这种景象是由如下起因造成的:
- backward 返回后,grad 数据只是以 one::Tensor 的状态存在,从未裸露给 Python 端,其指向 PyTensorObject 的 pyobject_ 指针还是空的。
- Python 环境下
id(a.grad)
的调用会创立一个新的 PyTensorObject 对象,然而a.grad
是一个长期对象,CPython 的 call_function 返回前须要调用 Py_DECREF 递加援用计数。 - 因为是长期对象、没有其它援用,调用 Py_DECREF 使援用计数为 0、导致 PyTensorObject_dealloc 被调用,pyobject_ 指针从新变为空。然而在 C++ 层面,grad 并没有被析构,还在 Tensor a 中保留着。
- 这样每次调用
id(a.grad)
都会反复创立、开释 PyTensorObject 对象,打印的 id 也就不是固定的。(然而对 one::Tensor 对象没有影响)
pull 9544 中曾经用一种奇妙的计划修复这个问题。
6.1 C++ 对象与 Python 对象的关系
C++ 是后端,Python 是前端。C++ 中的对象须要封装为 PyOjbect,能力在 Python 中供用户应用。
用一个比喻来形容二者的关系:C++ 对象是真身,PyObject 是幻影分身。真身是根底;幻影分身无奈脱离真身而独立存在。也就是说,能够只有 C++ 对象、而没有 PyObject;然而 PyObject 不能在没有 C++ 对象的状况下独立存在。
6.2 Tensor 对象的状态
之所以会呈现 issue 9500 中的问题,起因是之前认为对象只有两种状态:
- 未返回给 Python 端,或者援用计数曾经为 0。
- 返回给 Python 端,援用计数不为 0。
如果只容许这两种状态,就会呈现上述频繁创建对象的状况。所以 pull 9544 引入了一个新的 bool 变量,和原来的指针一起示意对象的状态。
- pyobj_ptr_: PyTensorObject 的智能指针。deleter 函数用于在 one::Tensor 对象析构时解决 PyTensorObject。
- owns_pyobj_: bool 变量,示意该 one::Tensor 是否持有对 PyTensorObject 的管理权限。
这样,one::Tensor 在生存期内就有三种非法状态:
-
状态 0:初始状态。one::Tensor 对象刚在 C++ 中创立结束,尚未返回给 Python 端。grad 在初始时就是这种状态。
- owns_pyobj_: false,没有 PyTensorObject 对象须要治理。
- pyobj_ptr_: nullptr。
-
状态 1:对象返回给 Python 端,援用计数大于 0。
a = flow.ones(2, 3)
就是这种状态。- owns_pyobj_: false,由 Python 运行时治理 PyTensorObject 对象。
- pyobj_ptr_: 不空。
-
状态 2:对象返回给 Python 端,但起初援用计数变为 0。
a = flow.ones(2, 3); del a
以及id(a.grad)
就是这种状态。- owns_pyobj_: true,由 one::Tensor 治理 PyTensorObject 对象。
- pyobj_ptr_: 持有 &PyTensorObject,援用计数为 1。
pyobj_ptr_ == nullptr && owns_pyobj_ == true
是一种非法状态。
6.3 Tensor 对象状态的转换
one::Tensor 的三种状态之间的转换关系如下:
在 v0.9.0 的代码中,PyTensor_tryResurrect 的作用是从状态 1 转为状态 2。之后 one::Tensor 可能会被析构,也可能不会。
PyTensor_wrap 的作用是从各种非法状态转为状态 2。三种状态转换的代码对应关系如下:
0 -> 1
1 -> 1
2 -> 1
6.4 PyTensor_tryResurrect 何时返回 false?
PyTensor_tryResurrect 中,只有在 one::Tensor 析构时,self->data
才是空的。调用链路如下:
- 进入 PyTensor_tryResurrect 时,如果 Python 环境中只有 self 一个援用,会将 PyTensorObject.data 置为空,这时只有 tensor 一个指针持有 one::Tensor 对象。
- PyTensor_tryResurrect 返回前,tensor 智能指针析构,顺次导致 one::Tensor 及其成员变量 pyobj_ptr_ 析构。
- pyobj_ptr_ 析构执行 deleter 函数,递加 PyTensorObject 的援用计数为 0,导致再次递归进入 PyTensorObject_dealloc,并递归进入 PyTensor_tryResurrect。
- 此时在 PyTensor_tryResurrect 中,PyTensorObject.data 为空,返回 false。
- 递归调用的 PyTensorObject_dealloc 中执行资源清理操作。
- 首次调用的 PyTensorObject_dealloc 间接返回。
7 附录
7.1 oneflow gdb 断点示例
启动 gdb
source /mnt/oneflow/build/source.sh
gdb --args python3
断点示例
set breakpoint pending on
# run 之前设置
break oneflow::one::TensorMetaCls_call
# flow.Tensor() 之后设置
break PyType_GenericAlloc
break typeobject.c:1169
break oneflow::one::PyTensorObject_init
# issue 9500 的断点
# backward 之前设置
break oneflow::one::Tensor::set_pyobject
# backward 之后设置
break oneflow::one::PyTensor_New
break oneflow::one::Tensor::~Tensor
7.2 CPython 宏开展命令示例
gcc -E -std=c99 \
-DCONFIG_64 \
-DPy_BUILD_CORE \
-D_POSIX_THREADS \
-I Include \
-I Include/internal \
-I Modules/_decimal/libmpdec \
-I PC \
Objects/typeobject.c > typeobject.c
8 参考资料
-
Python behind the scenes(中文)
- #3: stepping through the CPython source code
- #4: how Python bytecode is executed
- #6: how Python object system works
-
Python 官网文档
- Data model
- Defining Extension Types: Tutorial
- Type Objects
-
PyTensorObject 频繁创立、开释问题的修复
- github issue
- pull request
- CPython v3.10.9
- oneflow v0.9.0