关于c++:OneFlow-源码阅读-12从-Tensor-看-CPython-的对象创建过程

51次阅读

共计 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,因为不是每个对象都是可调用的。
  • 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

正文完
 0