一文让你搞懂 Python 虚拟机执行字节码的奥秘

简介: 一文让你搞懂 Python 虚拟机执行字节码的奥秘


楔子



当解释器启动后,首先会进行运行时环境的初始化。注意这里的运行时环境,它和之前说的执行环境是不同的概念。运行时环境是一个全局的概念,而执行环境是一个栈帧。

关于运行时环境的初始化是一个很复杂的过程,涉及到 Python 进程、线程的创建,类型对象的完善等非常多的内容,我们暂时先不讨论。这里就假设初始化动作已经完成,我们已经站在了虚拟机的门槛外面,只需要轻轻推动第一张骨牌,整个执行过程就像多米诺骨牌一样,一环扣一环地展开。

在介绍字节码的时候我们说过,解释器可以看成是:编译器+虚拟机,编译器负责将源代码编译成 PyCodeObject 对象,而虚拟机则负责执行。整个过程如下:

所以我们的重点就是虚拟机是怎么执行 PyCodeObject 对象的?整个过程是什么,掌握了这些,你对虚拟机会有一个更深的理解。


虚拟机的运行框架



在介绍栈帧的时候我们说过,Python 是一门动态语言,一个变量指向什么对象需要在运行时才能确定,这些信息不可能静态存储在 PyCodeObject 对象中。

所以虚拟机在运行时会基于 PyCodeObject 对象动态创建出一个栈帧对象,然后在栈帧里面执行字节码。而创建栈帧,主要使用以下两个函数:

// 基于 PyCodeObject、全局名字空间、局部名字空间,创建栈帧
// 参数非常简单,所以它一般适用于模块这种参数不复杂的场景
// 我们说模块也会对应一个栈帧,并且它位于栈帧链的最顶层
PyObject *
PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals);
// 它相比 PyEval_EvalCode 多了很多的参数
// 比如里面有位置参数以及个数,关键字参数以及个数
// 还有默认参数以及个数,闭包等等,显然它用于函数等复杂场景
PyObject *
PyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals,
                  PyObject *const *args, int argcount,
                  PyObject *const *kws, int kwcount,
                  PyObject *const *defs, int defcount,
                  PyObject *kwdefs, PyObject *closure);

但这两个函数都属于高层的封装,它们最终都会调用 _PyEval_EvalCodeWithName 函数。这个函数内部的逻辑我们就不看了,只需要知道它在执行完毕之后栈帧就创建好了,而栈帧才是我们的重点,因为代码在执行期间所依赖的上下文信息全部由栈帧来维护。

一旦栈帧对象初始化完毕,那么就要进行处理了,处理的时候会调用以下两个函数。

PyObject *
PyEval_EvalFrame(PyFrameObject *f);
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag);

当然啦,上面这两个函数同样属于高层的封装,最终会调用 _PyEval_EvalFrameDefault 函数,虚拟机就是通过该函数来完成字节码的执行

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag);

到目前为止总共出现了 6 个函数,用一张图来描述一下它们的关系:

782f85abbff9667ecd55f6edf7e9f3a8.png

所以 _PyEval_EvalFrameDefault 函数是虚拟机运行的核心,并且代码量很大。

4065e1d0274c6146fd6c28b6713b06c2.png

可以看到这一个函数大概在 3100 行左右,不过也仅仅是代码量大而已,因为它的逻辑很好理解。

// 源代码位于 Python/ceval.c 中
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{  
    //......
    co = f->f_code;
    names = co->co_names;
    consts = co->co_consts;
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;
    next_instr = first_instr;
    if (f->f_lasti >= 0) {
        assert(f->f_lasti % sizeof(_Py_CODEUNIT) == 0);
        next_instr += f->f_lasti / sizeof(_Py_CODEUNIT) + 1;
    }
    // 栈顶指针
    stack_pointer = f->f_stacktop;
    assert(stack_pointer != NULL);
    f->f_stacktop = NULL;       
    //......
}

该函数首先会初始化一些变量,PyCodeObject 对象包含的信息不用多说,还有一个重要的动作就是对指针 stack_pointer 进行初始化。stack_pointer 指向运行时栈的栈顶,关于运行时栈可能有人暂时还不理解它是做什么的,别急,马上你就知道它的作用了。

栈帧对象有两个重要字段是关于运行时栈的,f_stacktop 字段指向运行时栈的栈顶,f_valuestack 字段指向运行时栈的栈底。

所以对 stack_pointer 初始化的时候,将它的值初始化为 f->f_stacktop,让它指向运行时栈的栈顶。但操作运行时栈是通过 stack_pointer 操作的,随着元素的添加和删除,栈顶位置会变,所以后续它反过来还要再赋值给 f_stacktop。

然后栈帧中的 f_code 就是 PyCodeObject 对象,该对象的 co_code 字段则保存着字节码指令序列。而虚拟机执行字节码就是从头到尾遍历整个 co_code,对指令逐条执行的过程。

估计有人对栈帧和 PyCodeObject 对象的底层结构已经记不太清了,这里为了方便后续内容的理解,我们将它们的结构再展示一下。

30bc4a2d3c0df458201e7006e7559a95.png

所以字节码也叫作指令序列,它就是一个普普通通的 bytes 对象,对于 C 而言则是一个字符数组,一条指令就是一个字符、或者说一个整数。而在遍历的时候会使用以下两个变量:

  • first_instr:永远指向字节码指令序列的第一条字节码指令;
  • next_instr:永远指向下一条待执行的字节码指令;


当然别忘记栈帧的 f_lasti 成员,它记录了上一条已经执行过的字节码指令的偏移量。

多说一句,生成器之所以能够从中断的位置恢复执行,就是因为 f_lasti 记录了上一条执行的字节码指令的位置。

那么这个动作是如何一步步完成的呢?其实就是一个 for 循环加上一个巨大的 switch case 结构。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{   
    //......   
    co = f->f_code;
    names = co->co_names;
    consts = co->co_consts;
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;
    //......
  
    // 死循环
    for (;;) {
        if (_Py_atomic_load_relaxed(eval_breaker)) {
        // 不断地读取 co->co_code 中下一条待执行的字节码指令
            opcode = _Py_OPCODE(*next_instr);
        // opcode 就是字节码指令序列中的每一条指令
        // 指令有哪些都定义在 Include/opcode.h 中
            if (opcode == SETUP_FINALLY ||
                opcode == SETUP_WITH ||
                opcode == BEFORE_ASYNC_WITH ||
                opcode == YIELD_FROM) {
                goto fast_next_opcode; 
            }
        fast_next_opcode:
            // ......
            // 判断该指令属于什么操作,然后执行相应的逻辑
            switch (opcode) {
                // 加载一个局部变量
                case TARGET(LOAD_FAST):
                    // ......
                    break;
                // 加载一个常量
                case TARGET(LOAD_CONST):
                    // ......
                    break;
                // ......
        }
    }
}

在这个执行架构中,对字节码的遍历是通过宏来实现的:

#define INSTR_OFFSET()  \
    (sizeof(_Py_CODEUNIT) * (int)(next_instr - first_instr))
#define NEXTOPARG()  do { \
        _Py_CODEUNIT word = *next_instr; \
        opcode = _Py_OPCODE(word); \
        oparg = _Py_OPARG(word); \
        next_instr++; \
    } while (0)

首先每条字节码指令都会带有一个参数,co_code 中索引为 0 2 4 6 8... 的整数便是指令,索引为 1 3 5 7 9... 的整数便是参数。所以 co_code 里面并不全是字节码指令,每条指令后面都还跟着一个参数。因此 next_instr 每次向后移动两个字节,便可跳到下一条指令。

next_instr 和 first_instr 都是  _Py_CODEUNIT *  类型的变量,这个 _Py_CODEUNIT 是一个 uint16_t。所以只要执行 next_instr++,便可向后移动两字节,跳到下一条指令。

我们再看一下上面的宏,INSTR_OFFSET 计算的显然就是下一条待执行的指令和第一条指令之间的偏移量;然后是 NEXTOPARG,里面的变量 word 就是待执行的指令。

当然,由于 word 占两字节,所以也包括了参数。其中 word 的前 8 位是指令 opcode,后 8 位是参数 oparg。然后在解析出来指令以及参数之后,再执行 next_instr++,跳到下一条指令。

而接下来就要执行上面刚解析出来的字节码指令了,会利用 switch 语句对指令进行判断,根据判断的结果选择不同的 case 分支

97f977c8e641a08dd6c104e82cb7ae35.png

每一个 case 分支,对应一个字节码指令的实现,不同的指令执行不同的 case 分支。所以这个 switch case 语句非常的长,函数总共 3000 多行,这个 switch 就占了2400行。因为指令非常多,比如:LOAD_CONST, LOAD_NAME, YIELD_FROM等等,而每一个指令都要对应一个 case 分支

然后当匹配到的 case 分支执行完毕时,说明当前的这一条字节码指令就执行完毕了,那么虚拟机的执行流程会跳转到标签 fast_next_opcode 所在位置,或者 for 循环所在位置。但不管如何,虚拟机接下来的动作就是获取下一条字节码指令和指令参数,完成对下一条指令的执行。

所以通过 for 循环一条一条遍历 co_code 中包含的所有字节码指令,然后交给内部的 switch 语句、选择不同的 case 分支进行执行,如此周而复始,最终完成了对整个 Python 程序的执行。

尽管目前只是简单的分析,但相信你也能大体地了解 Python 执行引擎的整体结构。说白了 Python 虚拟机就是将自己当成一个 CPU,在栈帧中一条条的执行指令,而执行过程中所依赖的常量、变量等,则由栈帧的其它成员来维护。

因此在虚拟机的执行流程进入了那个巨大的 for 循环,并取出第一条字节码指令交给里面的 switch 语句之后,第一张多米诺骨牌就已经被推倒,命运不可阻挡的降临了。一条接一条的指令如同潮水般涌来,浩浩荡荡,横无际涯。


运行时栈的一些 API



这里先来简单介绍一下运行时栈,它是参数的容身之所,比如虚拟机在执行 a + b 的时候,知道这是一个加法操作。但在执行加法的时候,加号两边的值是多少,它要怎么获取呢?这时候就需要一个栈来专门保存相应的参数。

在执行加法之前,先将 a 和 b 压入栈中,然后执行加法的时候,再将 a 和 b 从栈里面弹出来即可。现在有一个印象,一会儿我们通过反编译查看字节码指令的时候,就一切都清晰了。

然后再来看看运行时栈相关的一些 API。

690d0293ba30c57a180949a73db937b1.png

API 非常多,但操作运行时栈都是通过操作 stack_pointer 实现的。假设运行时栈内部有三个元素,从栈底到栈顶分别是整数 1、2、3,那么运行时栈的结构就是下面这样。

0881b065e2752caf4990ee822836ff51.png

然后看一下这些和运行时栈相关的 API 都是干嘛的。

STACK_LEVEL():

#define STACK_LEVEL() \
     ((int)(stack_pointer - f->f_valuestack))

返回运行时栈的元素数量。

EMPTY():

#define EMPTY()      (STACK_LEVEL() == 0)

判断运行时栈是否为空。

TOP():

#define TOP()          (stack_pointer[-1])

查看当前运行时栈的栈顶元素。

SECOND():

#define SECOND()       (stack_pointer[-2])

查看从栈顶元素开始的第二个元素,所以随着元素不断添加,栈顶元素也在不断发生变化,而 stack_pointer 也在不断变化。

THIRD():

#define THIRD()        (stack_pointer[-3])

查看从栈顶元素开始的第三个元素。

FOURTH():

#define FOURTH()      (stack_pointer[-4])

查看从栈顶元素开始的第四个元素。

PEEK(n):

#define PEEK(n)       (stack_pointer[-(n)])

查看从栈顶元素开始的第 n 个元素。

SET_TOP(v):

#define SET_TOP(v)    (stack_pointer[-1] = (v))

将当前运行时栈的栈顶元素设置成 v,同理还有 SET_SECOND,SET_THIRD,SET_FOURTH,SET_VALUE。

PUSH(v):

往运行时栈中压入一个元素。

// 将 stack_pointer 设置成 v,然后再执行自增操作
#define BASIC_PUSH(v)   (*stack_pointer++ = (v))
// 也是调用了 BASIC_PUSH,但做了一层检测
// co_stacksize 表示运行时栈的大小,STACK_LEVEL() 不能超过它
#define PUSH(v)         { (void)(BASIC_PUSH(v), \
                          lltrace && prtrace(tstate, TOP(), "push")); \
                          assert(STACK_LEVEL() <= co->co_stacksize); }

假设当前运行时栈有 1、2、3 总共三个元素,我们往栈里面压入一个元素 4,那么运行时栈就会变成下面这个样子。

082c32200dc96f04089fa0dd84fc6c78.png

Python 的变量都是一个指针,所以 stack_pointer 是一个二级指针,它永远指向栈顶位置,只不过栈顶位置会变。

POP(v):

从运行时栈弹出一个元素,注意它和 TOP 的区别,TOP 是返回栈顶元素,但不弹出。

// 将 stack_pointer 先执行自减操作,然后解引用
#define BASIC_POP()    (*--stack_pointer)
#define POP()       ((void)(lltrace && prtrace(tstate, TOP(), "pop")), \
                     BASIC_POP())

假设当前运行时栈有 1、2、3 总共三个元素,我们弹出一个元素,那么运行时栈就会变成下面这个样子。

c927471b28ce394110c06b15497a5d88.png

stack_pointer 指向栈顶位置,所以它向栈底移动一个位置,就相当于元素被弹出了。


通过反编译查看字节码



我们写一段简单的代码,然后反编译,看看虚拟机是如何执行字节码的。

code = """\
chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3
"""
# 将上面的代码以模块的方式进行编译
co = compile(code, "...", "exec")
# 查看常量池
print(co.co_consts)  # (89, 99, 91, 3, None)
# 查看符号表
print(
    co.co_names
)  # ('chinese', 'math', 'english', 'avg')

在编译的时候,常量和符号(变量)都会被静态收集起来,然后我们反编译一下看看字节码,直接通过 dis.dis(co) 即可。结果如下:

1      0 LOAD_CONST               0 (89)
         2 STORE_NAME               0 (chinese)
  2      4 LOAD_CONST               1 (99)
         6 STORE_NAME               1 (math)
  3      8 LOAD_CONST               2 (91)
        10 STORE_NAME               2 (english)
  4     12 LOAD_NAME                0 (chinese)
        14 LOAD_NAME                1 (math)
        16 BINARY_ADD
        18 LOAD_NAME                2 (english)
        20 BINARY_ADD
        22 LOAD_CONST               3 (3)
        24 BINARY_TRUE_DIVIDE
        26 STORE_NAME               3 (avg)
        28 LOAD_CONST               4 (None)
        30 RETURN_VALUE

解释一下每一列的含义:

  • 第一列是源代码的行号
  • 第二列是指令的偏移量,或者说该指令在整个字节码指令序列中的索引。因为每条指令后面都跟着一个参数,所以偏移量是 0 2 4 6 8 ...;
  • 第三列是字节码指令,简称指令,它们在宏定义中代表整数;
  • 第四列是字节码指令参数,简称指令参数、或者参数,不同的指令参数的含义不同;
  • 第五列是 dis 模块给我们额外提供的信息,一会说;


我们从上到下依次解释每条指令都干了什么?

0 LOAD_CONST:表示加载一个常量(指针),并压入运行时栈。后面的指令参数 0 表示从常量池中加载索引为 0 的常量,至于 89 则表示加载的常量是 89。所以最后面的括号里面的内容实际上起到的是一个提示作用,告诉你加载的对象是什么。

2 STORE_NAME:表示将 LOAD_CONST 加载的常量用一个名字绑定起来,放在所在的名字空间中。后面的 0 (chinese) 则表示使用符号表中索引为 0 的名字(符号),且名字为 "chinese"。

所以像 chinese = 89 这种简单的赋值语句,会对应两条字节码指令。

然后 4 LOAD_CONST、6 STORE_NAME 和 8 LOAD_CONST、10 STORE_NAME 的作用显然和上面是一样的,都是加载一个常量,然后将某个符号和常量绑定起来,并放在名字空间中。

12 LOAD_NAME:加载一个变量,并压入运行时栈。而后面的 0 (chinese) 表示加载符号表中索引为 0 的变量的值,然后这个变量叫 chinese。14 LOAD_NAME 也是同理,将符号表中索引为 1 的变量的值压入运行时栈,并且变量叫 math。此时栈里面有两个元素,从栈底到栈顶分别是 chinese 和 math。

16 BINARY_ADD:将上面两个变量从运行时栈弹出,然后执行加法操作,并将结果压入运行时栈。

18 LOAD_NAME:将符号表中索引为 2 的变量 english 的值压入运行时栈,此时栈里面有两个元素,从栈底到栈顶分别是 chinese + math 的返回结果english

20 BINARY_ADD:将运行时栈里的两个元素弹出,然后执行加法操作,并将结果压入运行时栈。此时栈里面有一个元素,就是 chinese + math + english 的返回结果。

22 LOAD_CONST:将常量 3 压入运行时栈,此时栈里面有两个元素;

22 BINARY_TRUE_DIVIDE:将运行时栈里的两个元素弹出,然后执行除法操作,并将结果压入运行时栈,此时栈里面有一个元素;

24 STORE_NAME:将元素从运行时栈里面弹出,并用符号表中索引为 3 的变量 avg 和它绑定起来,然后放在名字空间中。

28 LOAD_CONST:将常量 None 压入运行时栈,然后通过 30 RETURN_VALUE 将其从栈中弹出,然后返回。

所以 Python 虚拟机就是把自己想象成一颗 CPU,在栈帧中一条条执行字节码指令,当指令执行完毕或执行出错时,停止执行。

我们通过几张图展示一下上面的过程,为了阅读方便,这里将相应的源代码再贴一份:

chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3

我们说模块也有自己的作用域,并且是全局作用域,所以虚拟机也会为它创建栈帧。而在代码还没有执行的时候,栈帧就已经创建好了,整个布局如下。

349a0e0894ced886c199e956f0a4b3bb.png

这里补充一个知识点,非常重要,首先我们看到栈帧里面有一个 f_localsplus 属性,它是一个数组。虽然声明的时候写着长度为 1,但实际使用时,长度不受限制,和 Go 语言不同,C 数组的长度不属于类型的一部分。

所以 f_localsplus 是一个动态内存,运行时栈所需要的空间就存储在里面。但这块内存并不光给运行时栈使用,它被分成了四块。

b5d4339d3a63d4d3c675a9aaddee0455.png

函数的局部变量是静态存储的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是开头的位置。在获取的时候直接基于索引操作即可,因此速度会更快。所以源码内部还有两个宏:

de433dae6834aac235ff6f1d6a1cb807.png

fastlocals 就是栈帧的 f_localsplus,而函数在编译的时候就知道某个局部变量在 f_localsplus 中的索引,所以通过 GETLOCAL 获取即可。同理 SETLOCAL 则是创建一个局部变量。

至于 cell 对象和 free 对象则是用来处理闭包的,而 f_localsplus 的最后一块内存则用于运行时栈。

所以 f_localsplus 是一个数组,它是一段连续内存,只不过从逻辑上讲,它被分成了四份,每一份用在不同的地方。但它们整体是连续的,都是数组的一部分。按照新一团团长丁伟的说法:彼此是鸡犬相闻,但又老死不相往来。

但我们当前是以模块的方式编译的,里面所有的变量都是全局变量,而且也不涉及闭包啥的,所以这里就把 f_localsplus 理解为运行时栈即可。

接下来就开始执行字节码了,next_instr 指向下一条待执行的字节码指令,显然初始状态下,下一条待执行的指令就是第一条指令。

于是虚拟机开始加载:0 LOAD_CONST,该指令表示将常量加载进运行时栈,而要加载的常量在常量池中的索引,由指令参数表示。

在源码中,指令对应的变量是 opcode,指令参数对应的变量是 oparg

// 代码位于 Python/ceval.c 中
case TARGET(LOAD_CONST): {
    // 调用元组的 GETITEM 方法
    // 从常量池中加载索引为 oparg 的对象(常量)
    // 当然啦,这里为了方便称其为对象,但其实是指向对象的指针
    PREDICTED(LOAD_CONST);
    PyObject *value = GETITEM(consts, oparg);
    // 增加引用计数
    Py_INCREF(value);
    // 压入运行时栈
    PUSH(value);
    FAST_DISPATCH();
}

该指令的参数为 0,所以会将常量池中索引为 0 的元素 89 压入运行时栈,执行完之后,栈帧的布局就变成了下面这样:

8932b468b9f1dcc5ca9cfdc107ee9de1.png

f_localsplus 下面的箭头方向,代表运行时栈从栈底到栈顶的方向。

接着虚拟机执行 2 STORE_NAME 指令,从符号表中获取索引为 0 的符号、即 chinese。然后将栈顶元素 89 弹出,再将符号 chinese 整数对象 89 绑定起来保存到 local 名字空间中。

case TARGET(STORE_NAME): {
    // 从符号表中加载索引为 oparg 的符号  
    // 符号本质上就是一个 PyUnicodeObject 对象
    // 这里就是字符串 "chinese"
    PyObject *name = GETITEM(names, oparg);
    // 从运行时栈的栈顶弹出元素
    // 显然是上一步压入的 89
    PyObject *v = POP();
    // 获取名字空间 namespace
    PyObject *ns = f->f_locals;
    int err;
    if (ns == NULL) {
        // 如果没有名字空间则报错,设置异常
        // 这个 tstate 是和线程密切相关的
        _PyErr_Format(tstate, PyExc_SystemError,
                      "no locals found when storing %R", name);
        Py_DECREF(v);
        goto error;
    }
    // 将符号和对象绑定起来放在 ns 中
    // 名字空间是一个字典,PyDict_CheckExact 则检测 ns 是否为字典
    if (PyDict_CheckExact(ns))
        // PyDict_CheckExact(ns) 类似于 type(ns) is dict
        // 除此之外,还有 PyDict_Check(ns)
        // 它类似于 isinstance(ns, dict),检测标准相对要宽松一些
        // 然后将键值对 "chinese": 89 设置到字典中
        err = PyDict_SetItem(ns, name, v);
    else
        // 走到这里说明 type(ns) 不是 dict,那么它应该继承 dict
        // 设置元素
        err = PyObject_SetItem(ns, name, v);
        
    // 对象的引用计数减 1,因为从运行时栈中弹出了
    Py_DECREF(v);
    // 如果 err != 0,证明设置元素出错了,跳转至 error 标签
    if (err != 0)
        goto error;
    DISPATCH();
}

执行完之后,栈帧的布局就变成了下面这样: 8932b468b9f1dcc5ca9cfdc107ee9de1.png

此时运行时栈为空,local 名字空间多了个键值对。

同理剩余的两个赋值语句也是类似的,只不过指令参数不同,比如 6 STORE_NAME 加载的是符号表中索引为 1 的符号,8 STORE_NAME 加载的是符号表中索引为 2 的符号,分别是 math 和 english。

b331e67fe1fc6bd71ab45e1a5564cec6.png

然后 12 LOAD_NAME14 LOAD_NAME 负责将符号表中索引为 0 和 1 的变量的值压入运行时栈:

case TARGET(LOAD_NAME): {
    // 从符号表 co_names 中加载索引为 oparg 的变量
    // 但是注意:全局变量是通过字典存储的
    // 所以这里的 name 只是一个字符串罢了,比如 "chinese"
    // 然后还要再根据这个字符串从字典里面查找对应的 value
    PyObject *name = GETITEM(names, oparg);
    // 对于模块来说,f->f_locals 和 f->f_globals 指向同一个字典
    PyObject *locals = f->f_locals;
    PyObject *v;
    // ....
    if (PyDict_CheckExact(locals)) {
        // 根据 name 获取 value
        // 所以 print(chinese) 本质上就是下面这样
        // print(locals["chinese"])
        v = PyDict_GetItemWithError(locals, name);
        if (v != NULL) {
            Py_INCREF(v);
        }
        else if (_PyErr_Occurred(tstate)) {
            goto error;
        }
    }
    else {
        // ...
    }
    // ...
    // 将符号表中索引为 oparg 的变量的值
    // 压入运行时栈
    PUSH(v);
    DISPATCH();
}

上面两条指令执行完之后,栈帧的布局就变成了下面这样:

253a0fc722b5e3abfeb6d3626f16568b.png

接下来执行 16 BINARY_ADD,它会将栈里的两个元素弹出,然后执行加法操作,最后再将结果入栈。

当然上面这种说法是为了方便理解,其实虚拟机真正执行的时候,只会弹出一个元素,而另一个元素只是使用 TOP() 进行查看,但不弹出。将结果计算完毕之后,再将栈顶元素替换掉。


所以本质上,和弹出两个元素、再将计算结果入栈是一样的。

case TARGET(BINARY_ADD): {
    // 从栈顶弹出元素,这里是 99(变量 math),
    PyObject *right = POP();
    // math 弹出之后,chinese 就成为了新的栈顶元素
    // 这里的 TOP() 则是获取栈顶元素 89(变量 chinese)
    PyObject *left = TOP();
    // 用于保存两者的和
    PyObject *sum;
    
    // 如果是字符串,执行专门的函数
    if (PyUnicode_CheckExact(left) &&
             PyUnicode_CheckExact(right)) {
        sum = unicode_concatenate(tstate, left, right, f, next_instr);
    }
    // 否则通过泛型 API PyNumber_Add 进行计算
    else {
        sum = PyNumber_Add(left, right);
        Py_DECREF(left);
    }
    // 从栈里面弹出,所以减少引用计数
    Py_DECREF(right);
    // 将栈顶元素替换成 sum
    SET_TOP(sum);
    if (sum == NULL)
        goto error;
    DISPATCH();
}

BINARY_ADD 指令执行完之后,栈帧的布局就变成了下面这样:

7ee5183984dcf8b6cdfa0bccaec18314.png

然后 18 LOAD_NAME 负责将符号表中索引为 2 的变量 english 的值压入运行时栈;而指令 20 BINARY_ADD 则是继续执行加法操作,并将结果设置在栈顶;然后 22 LOAD_CONST 将常量 3 再压入运行时栈。

这三条指令执行之后,运行时栈变化如下:

96541f51088ff7c6254b0a2795686062.png

接着是 24 BINARY_TRUE_DIVIDE,它的逻辑和 BINARY_ADD 类似,只不过一个执行除法,一个执行加法。

case TARGET(BINARY_TRUE_DIVIDE): {
    // 从栈顶弹出元素,显然是 3
    PyObject *divisor = POP();
    // 查看栈顶元素,此时栈顶元素变成了 279
    PyObject *dividend = TOP();
    // 调用 PyNumber_TrueDivide,执行 279 / 3
    PyObject *quotient = PyNumber_TrueDivide(dividend, divisor);
    // 从栈里面弹出,减少引用计数
    Py_DECREF(dividend);
    Py_DECREF(divisor);
    // 将栈顶元素替换为 279 / 3 的计算结果
    SET_TOP(quotient);
    if (quotient == NULL)
        goto error;
    DISPATCH();
}

24 BINARY_TRUE_DIVIDE 执行完之后,运行时栈如下:

eda7d6e3100ffe6f0073e2fa4d589677.png

然后 26 STORE_NAME 将栈顶元素 93.0 弹出,并将符号表中索引为 3 的变量 avg 和它绑定起来,放到名字空间中。因此最终栈帧关系图如下:

9611d7b6e5863f66f3febb89259b258e.png

以上就是虚拟机对这几行代码的执行流程,整个过程就像 CPU 执行指令一样。

我们再用 Python 代码描述一遍上面的逻辑:

# LOAD_CONST 将 89 压入栈中
# STORE_NAME 将 89 从栈中弹出
# 并将符号 "chinese" 和 89 绑定起来,放在名字空间中
chinese = 89
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89}
math = 99
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99}
english = 91
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99, 'english': 91}
avg = (chinese + math + english) / 3
print(
    {k: v for k, v in locals().items() if not k.startswith("__")}
)  # {'chinese': 89, 'math': 99, 'english': 91, 'avg': 93.0}

现在你是不是对虚拟机执行字节码有一个更深的了解了呢?当然字节码指令有很多,不止我们上面看到的那几个。你可以随便写一些代码,然后分析一下它的字节码指令是什么样的。

指令都定义在 Include/opcode.h 中


变量赋值时用到的指令



这里我们来介绍几个在变量赋值的时候,所用到的指令。因为出现频率极高,所以有必要单独说一下。

64c8dcc62639981d9c308b613e32ca69.png

下面来实际操作一波,看看这些指令:

0e0d51a1ee6fc7bf141b5e8c51630958.png

0 LOAD_CONST:加载字符串常量 "female";

2 STORE_FAST:在局部作用域中定义一个局部变量 gender,和字符串对象 "female" 建立映射关系,本质上就是让变量 gender 保存这个字符串对象的地址;

4 LOAD_GLOBAL:在局部作用域中加载一个内置变量 print;

6 LOAD_FAST:在局部作用域中加载一个局部变量 gender;

14 LOAD_GLOBAL:在局部作用域中加载一个全局变量 name;

7f25a23136324cea181891ef43247ee9.png

0 LOAD_CONST:加载字符串常量 "古明地恋";

2 STORE_GLOBAL:在局部作用域中定义一个被 global 关键字声明的全局变量;

12ae92c602ef71443027b53fffd7f208.png

0 LOAD_CONST:加载字符串常量 "古明地觉";

2 STORE_NAME:在全局作用域中定义一个全局变量 name,并和上面的字符串对象进行绑定;

4 LOAD_NAME:在全局作用域中加载一个内置变量 print;

6 LOAD_NAME:在全局作用域中加载一个全局变量 name;

以上我们就通过代码实际演示了这些指令的作用,它们和常量、变量的加载,以及变量的定义密切相关,可以说常见的不能再常见了。你写的任何代码在反编译之后都少不了它们的身影,因此有必要提前解释一下。

不管加载的是常量、还是变量,得到的永远是指向对象的指针。


变量赋值的具体细节



这里再通过变量赋值感受一下字节码的执行过程,首先关于变量赋值,你平时是怎么做的呢?

446a2866da0e7d8df325e5440f2e8c3d.png

这些赋值语句背后的原理是什么呢?我们通过字节码来逐一回答。

1)a, b = b, a 的背后原理是什么?

想要知道背后的原理,查看它的字节码是我们最好的选择。

0 LOAD_NAME                0 (b)
  2 LOAD_NAME                1 (a)
  4 ROT_TWO
  6 STORE_NAME               1 (a)
  8 STORE_NAME               0 (b)

里面关键的就是 ROT_TWO 指令,虽然我们还没看这个指令,但也能猜出来它负责交换栈里面的两个元素。假设 a 和 b 的值分别为 22、33,整个过程如下:

5ca81b9abf2e6804291170254233f591.png

来看一下 ROT_TWO 指令。

case TARGET(ROT_TWO): {
    // 获取栈顶元素,由于 b 先入栈、a 后入栈
    // 再加上栈是先入后出,所以这里获取的栈顶元素就是 a
    PyObject *top = TOP();
    // 运行时栈的第二个元素就是 b
    // TOP 是查看栈顶元素、SECOND 是查看栈的第二个元素
    // 并且这两个宏只是获取,不会将元素从栈中弹出
    PyObject *second = SECOND();
    // 将栈顶元素设置为 second,这里显然就是变量 b
    // 将栈的第二个元素设置为 top,这里显然就是变量 a
    SET_TOP(second);
    SET_SECOND(top);
    FAST_DISPATCH();
}

因此执行完 ROT_TWO 指令之后,栈顶元素就是 b,栈的第二个元素就是 a。然后后面的两个 STORE_NAME 会将栈里面的元素 b、a 依次弹出,赋值给 a、b,从而完成变量交换。

2)a, b, c = c, b, a 的背后原理是什么?

老规矩,还是查看字节码,因为一切真相都隐藏在字节码当中。

0 LOAD_NAME                0 (c)
  2 LOAD_NAME                1 (b)
  4 LOAD_NAME                0 (a)
  6 ROT_THREE
  8 ROT_TWO
 10 STORE_NAME               2 (a)
 12 STORE_NAME               1 (b)
 14 STORE_NAME               0 (c)

整个过程和 a, b = b, a 是相似的,首先 LOAD_NAME 将变量 c、b、a 依次压入栈中。由于栈先入后出的特性,此时栈的三个元素按照顺序(从栈顶到栈底)分别是 a、b、c。

然后是 ROT_THREE 和 ROT_TWO,毫无疑问,这两个指令执行完之后,会将栈的三个元素调换顺序,也就是将 a、b、c 变成 c、b、a。

最后 STORE_NAME 将栈的三个元素 c、b、a 依次弹出,分别赋值给 a、b、c,从而完成变量的交换。

因此核心就在 ROT_THREE 和 ROT_TWO 上面,由于后者上面已经说过了,所以我们看一下 ROT_THREE。

case TARGET(ROT_THREE): {
    PyObject *top = TOP();
    PyObject *second = SECOND();
    PyObject *third = THIRD();
    SET_TOP(second);
    SET_SECOND(third);
    SET_THIRD(top);
    FAST_DISPATCH();
}

栈顶元素是 top、栈的第二个元素是 second、栈的第三个元素是 third,然后将栈顶元素设置为 second、栈的第二个元素设置为 third、栈的第三个元素设置为 top。

所以栈里面的 a、b、c 在经过 ROT_THREE 之后就变成了 b、c、a,显然这还不是正确的结果。于是继续执行 ROT_TWO,将栈的前两个元素进行交换,执行完之后就变成了 c、b、a。

假设 a、b、c 的值分别为 "a"、"b"、"c",整个过程如下:

a444c3ca4029b4fad2a6f8a23a18e7c0.png

3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么区别呢?

我们还是看一下字节码。

0 LOAD_NAME                0 (d)
  2 LOAD_NAME                1 (c)
  4 LOAD_NAME                2 (b)
  6 LOAD_NAME                3 (a)
  8 BUILD_TUPLE              4
 10 UNPACK_SEQUENCE          4
 12 STORE_NAME               3 (a)
 14 STORE_NAME               2 (b)
 16 STORE_NAME               1 (c)
 18 STORE_NAME               0 (d)

依旧是将等号右边的变量,按照从左往右的顺序,依次压入栈中,但此时没有直接将栈里面的元素做交换,而是构建一个元组。因为往栈里面压入了四个元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示构建长度为 4 的元组。

case TARGET(BUILD_TUPLE): {
    PyObject *tup = PyTuple_New(oparg);
    if (tup == NULL)
        goto error;
    // 元素从栈顶到栈底依次是 a、b、c、d
    // 所以元素弹出也是这个顺序
    // 但是注意循环,元素是从后往前设置的
    // 所以 item[3], item[2], item[1], item[0] = a, b, c, d
    while (--oparg >= 0) {
        PyObject *item = POP();
        PyTuple_SET_ITEM(tup, oparg, item);
    }
    // 将元组 item 压入栈中,元组为 (d, c, b, a)
    PUSH(tup);
    DISPATCH();
}

此时栈里面只有一个元素,指向一个元组。接下来是 UNPACK_SEQUENCE,负责对序列进行解包,它的指令参数也是 4,表示要解包的序列的长度为 4,我们来看看它的逻辑。

case TARGET(UNPACK_SEQUENCE): {
    PREDICTED(UNPACK_SEQUENCE);
    // seq:从栈里面弹出的元组 (d, c, b, a)
    // item:用于遍历元素
    // items:指向一个 PyObject * 类型的数组
    PyObject *seq = POP(), *item, **items;
    if (PyTuple_CheckExact(seq) &&
        PyTuple_GET_SIZE(seq) == oparg) {
        // 获取元组内部的 ob_item 成员
        // 元素就存储在它指向的数组中
        items = ((PyTupleObject *)seq)->ob_item;
        // 遍历内部的每一个元素,并依次压入栈中
        // 由于是从后往前遍历的,所以遍历的元素依次是 a b c d
        // 但在压入栈中之后,元素从栈顶到栈底就变成了 d c b a
        while (oparg--) {
            item = items[oparg];
            Py_INCREF(item);
            PUSH(item);
        }
    } else if (PyList_CheckExact(seq) &&
               PyList_GET_SIZE(seq) == oparg) {
        // 该指令同样适用于列表,逻辑一样
        items = ((PyListObject *)seq)->ob_item;
        while (oparg--) {
            item = items[oparg];
            Py_INCREF(item);
            PUSH(item);
        }
    } 
    // ...
    Py_DECREF(seq);
    DISPATCH();
}

最后 STORE_NAME 将 d c b a 依次弹出,赋值给变量 a b c d,从而完成变量交换。所以当交换的变量多了之后,不会直接在运行时栈里面操作,而是将栈里面的元素挨个弹出,构建元组;然后再按照指定顺序,将元组里面的元素重新压到栈里面。

假设变量 a b c d 的值分别为 1 2 3 4,我们画图来描述一下整个过程。

9047ade4da311b3ca7cc05bb06b39378.png

不管是哪一种做法,Python在进行变量交换时所做的事情是不变的,核心分为三步走。首先将等号右边的变量,按照从左往右的顺序,依次压入栈中;然后对运行时栈里面元素的顺序进行调整;最后再将运行时栈里面的元素挨个弹出,还是按照从左往右的顺序,再依次赋值给等号左边的变量。

只不过当变量不多时,调整元素位置会直接基于栈进行操作;而当达到四个时,则需要额外借助于元组。

然后多元赋值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字节码。

0 LOAD_CONST               0 ((1, 2, 3))
  2 UNPACK_SEQUENCE          3
  4 STORE_NAME               0 (a)
  6 STORE_NAME               1 (b)
  8 STORE_NAME               2 (c)

元组直接作为一个常量被加载进来了,然后解包,再依次赋值。

4)a, b, c, d = d, c, b, a 和 a, b, c, d = [d, c, b, a] 有区别吗?

答案是没有区别,两者在反编译之后对应的字节码指令只有一处不同。

0 LOAD_NAME                0 (d)
  2 LOAD_NAME                1 (c)
  4 LOAD_NAME                2 (b)
  6 LOAD_NAME                3 (a)
  8 BUILD_LIST               4
 10 UNPACK_SEQUENCE          4
 12 STORE_NAME               3 (a)
 14 STORE_NAME               2 (b)
 16 STORE_NAME               1 (c)
 18 STORE_NAME               0 (d)

前者是 BUILD_TUPLE,现在变成了 BUILD_LIST,其它部分一模一样,所以两者的效果是相同的。当然啦,由于元组的构建比列表快一些,因此还是推荐第一种写法。

5)a = b = c = 123 背后的原理是什么?

如果变量 a、b、c 指向的值相同,比如都是 123,那么便可以通过这种方式进行链式赋值。那么它背后是怎么做的呢?

0 LOAD_CONST               0 (123)
  2 DUP_TOP
  4 STORE_NAME               0 (a)
  6 DUP_TOP
  8 STORE_NAME               1 (b)
 10 STORE_NAME               2 (c)

出现了一个新的字节码指令 DUP_TOP,只要搞清楚它的作用,事情就简单了。

case TARGET(DUP_TOP): {
    // 获取栈顶元素,注意是获取、不是弹出
    // TOP:查看元素,POP:弹出元素
    PyObject *top = TOP();
    // 增加指向对象的引用计数
    Py_INCREF(top);
    // 压入栈中
    PUSH(top);
    FAST_DISPATCH();
}

所以 DUP_TOP 干的事情就是将栈顶元素拷贝一份,再重新压到栈里面。另外不管链式赋值语句中有多少个变量,模式都是一样的.

我们以 a = b = c = d = e = 123 为例:

0 LOAD_CONST               0 (123)
   2 DUP_TOP
   4 STORE_NAME               0 (a)
   6 DUP_TOP
   8 STORE_NAME               1 (b)
  10 DUP_TOP
  12 STORE_NAME               2 (c)
  14 DUP_TOP
  16 STORE_NAME               3 (d)
  18 STORE_NAME               4 (e)

将常量压入运行时栈,然后拷贝一份,赋值给 a;再拷贝一份,赋值给 b;再拷贝一份,赋值给 c;再拷贝一份,赋值给 d;最后自身赋值给 e。

以上就是链式赋值的秘密,其实没有什么好神奇的,就是将栈顶元素进行拷贝,再依次赋值。

但是这背后有一个坑,就是给变量赋的值不能是可变对象,否则容易造成 BUG。

a = b = c = {}
a["ping"] = "pong"
print(a)  # {'ping': 'pong'}
print(b)  # {'ping': 'pong'}
print(c)  # {'ping': 'pong'}

虽然 Python 一切皆对象,但对象都是通过指针来间接操作的。所以 DUP_TOP 是将字典的地址拷贝一份,而字典只有一个,因此最终 a、b、c 会指向同一个字典。

6)a is b 和 a == b 的区别是什么?

is 用于判断两个变量是不是引用同一个对象,也就是保存的对象的地址是否相等;而 == 则是判断两个变量引用的对象是否相等,等价于 a.__eq__(b)

Python 的变量在 C 看来只是一个指针,因此两个变量是否指向同一个对象,等价于 C 中的两个指针存储的地址是否相等;


而 Python 的 ==,则需要调用 PyObject_RichCompare,来比较它们指向的对象所维护的值是否相等。

这两个语句的字节码指令是一样的,唯一的区别就是指令 COMPARE_OP 的参数不同。

// a is b 
  0 LOAD_NAME                0 (a)
  2 LOAD_NAME                1 (b)
  4 COMPARE_OP               8 (is)
  6 POP_TOP
  
  // a == b
  0 LOAD_NAME                0 (a)
  2 LOAD_NAME                1 (b)
  4 COMPARE_OP               2 (==)
  6 POP_TOP

我们看到指令参数一个是 8、一个是 2,然后是 COMPARE_OP 指令的背后逻辑:

case TARGET(COMPARE_OP): {
    // 弹出栈顶元素,这里是 b
    PyObject *right = POP();
    // 显然 left 就是 a
    // b 被弹出之后,它成为新的栈顶
    PyObject *left = TOP();
    // 进行比较,比较结果为 res
    PyObject *res = cmp_outcome(tstate, oparg, left, right);
    // 减少 left 和 right 引用计数
    Py_DECREF(left);
    Py_DECREF(right);
    // 将栈顶元素替换为 res
    SET_TOP(res);
    if (res == NULL)
        goto error;
    // 指令预测,暂时不用管
    PREDICT(POP_JUMP_IF_FALSE);
    PREDICT(POP_JUMP_IF_TRUE);
    // 相当于 continue
    DISPATCH();
}

所以逻辑很简单,核心就在 cmp_outcome 函数中。

static PyObject *
cmp_outcome(int op, PyObject *v, PyObject *w)
{  
    int res = 0;
    // op 就是 COMPARE_OP 指令的参数
    switch (op) {
    // PyCmp_IS 是一个枚举变量,等于 8
    // 定义在 Include/opcode.h 中
    case PyCmp_IS:
        // is 操作符,在 C 的层面直接一个 == 判断即可
        res = (v == w);
        break;
    // ...
    default:
        // 而 PyObject_RichCompare 是一个函数调用
        // 比较对象维护的值是否相等
        return PyObject_RichCompare(v, w, op);
    }
    v = res ? Py_True : Py_False;
    Py_INCREF(v);
    return v;
}

我们实际举个栗子:

a = 3.14
b = float("3.14")
print(a is b)  # False
print(a == b)  # True

a 和 b 都是 3.14,两者是相等的,但不是同一个对象。

反过来也是如此,如果 a is b 成立,那么 a == b 也不一定成立。可能有人好奇,a is b 成立说明 a 和 b 指向的是同一个对象,那么 a == b 表示该对象和自己进行比较,结果应该始终是相等的呀,为啥也不一定成立呢?以下面两种情况为例:

class Girl:
    def __eq__(self, other):
        return False
g = Girl()
print(g is g)  # True
print(g == g)  # False

__eq__ 返回 False,此时虽然是同一个对象,但是两者不相等。

import math
import numpy as np
a = float("nan")
b = math.nan
c = np.nan
print(a is a, a == a)  # True False
print(b is b, b == b)  # True False
print(c is c, c == c)  # True False

nan 是一个特殊的浮点数,意思是 not a number(不是一个数字),用于表示空值。而 nan 和所有数字的比较结果均为 False,即使是和它自身比较。


但需要注意的是,在使用 == 进行比较的时候虽然是不相等的,但如果放到容器里面就不一定了。举个例子:

import numpy as np
lst = [np.nan, np.nan, np.nan]
print(lst[0] == np.nan)  # False
print(lst[1] == np.nan)  # False
print(lst[2] == np.nan)  # False
# lst 里面的三个元素和 np.nan 均不相等
# 但是 np.nan 位于列表中,并且数量是 3
print(np.nan in lst)  # True
print(lst.count(np.nan))  # 3

出现以上结果的原因就在于,元素被放到了容器里,而容器的一些 API 在比较元素时会先判定它们存储的对象的地址是否相同,即:是否指向了同一个对象。如果是,直接认为相等;否则,再去比较对象维护的值是否相等。

可以理解为先进行 is 判断,如果结果为 True,直接判定两者相等;如果 is 操作的结果不为 True,再去进行 == 判断。

因此 np.nan in lst 的结果为 True,lst.count(np.nan) 的结果是 3,因为它们会先比较对象的地址。地址相同,则直接认为对象相等。

在用 pandas 做数据处理的时候,nan 是一个非常容易坑的地方。

提到 is 和 ==,那么问题来了,在和 True、False、None 比较时,是用 is 还是用 == 呢?

由于 True、False、None 它们不仅是关键字,而且也被看做是一个常量,最重要的是它们都是单例的,所以我们应该用 is 判断。


另外 is 在底层只需要一个 == 即可完成;但 Python 的 ==,在底层则需要调用 PyObject_RichCompare 函数。因此 is 在速度上也更有优势,== 操作肯定比函数调用要快。



小结



以上我们就研究了虚拟机是如何执行字节码的,相信你对 Python 虚拟机也有了更深的了解。说白了虚拟机就是把自己当成一颗 CPU,在栈帧中不停地执行字节码指令。


而执行逻辑就是 _PyEval_EvalFrameDefault 里面的那个大大的 for 循环,for 循环里面有一个巨型 switch,case 了所有的分支,不同的分支执行不同的逻辑。

相关文章
|
21天前
|
弹性计算 人工智能 架构师
阿里云携手Altair共拓云上工业仿真新机遇
2024年9月12日,「2024 Altair 技术大会杭州站」成功召开,阿里云弹性计算产品运营与生态负责人何川,与Altair中国技术总监赵阳在会上联合发布了最新的“云上CAE一体机”。
阿里云携手Altair共拓云上工业仿真新机遇
|
17天前
|
机器学习/深度学习 算法 大数据
【BetterBench博士】2024 “华为杯”第二十一届中国研究生数学建模竞赛 选题分析
2024“华为杯”数学建模竞赛,对ABCDEF每个题进行详细的分析,涵盖风电场功率优化、WLAN网络吞吐量、磁性元件损耗建模、地理环境问题、高速公路应急车道启用和X射线脉冲星建模等多领域问题,解析了问题类型、专业和技能的需要。
2563 22
【BetterBench博士】2024 “华为杯”第二十一届中国研究生数学建模竞赛 选题分析
|
15天前
|
人工智能 IDE 程序员
期盼已久!通义灵码 AI 程序员开启邀测,全流程开发仅用几分钟
在云栖大会上,阿里云云原生应用平台负责人丁宇宣布,「通义灵码」完成全面升级,并正式发布 AI 程序员。
|
13天前
|
存储 关系型数据库 分布式数据库
GraphRAG:基于PolarDB+通义千问+LangChain的知识图谱+大模型最佳实践
本文介绍了如何使用PolarDB、通义千问和LangChain搭建GraphRAG系统,结合知识图谱和向量检索提升问答质量。通过实例展示了单独使用向量检索和图检索的局限性,并通过图+向量联合搜索增强了问答准确性。PolarDB支持AGE图引擎和pgvector插件,实现图数据和向量数据的统一存储与检索,提升了RAG系统的性能和效果。
|
17天前
|
机器学习/深度学习 算法 数据可视化
【BetterBench博士】2024年中国研究生数学建模竞赛 C题:数据驱动下磁性元件的磁芯损耗建模 问题分析、数学模型、python 代码
2024年中国研究生数学建模竞赛C题聚焦磁性元件磁芯损耗建模。题目背景介绍了电能变换技术的发展与应用,强调磁性元件在功率变换器中的重要性。磁芯损耗受多种因素影响,现有模型难以精确预测。题目要求通过数据分析建立高精度磁芯损耗模型。具体任务包括励磁波形分类、修正斯坦麦茨方程、分析影响因素、构建预测模型及优化设计条件。涉及数据预处理、特征提取、机器学习及优化算法等技术。适合电气、材料、计算机等多个专业学生参与。
1556 16
【BetterBench博士】2024年中国研究生数学建模竞赛 C题:数据驱动下磁性元件的磁芯损耗建模 问题分析、数学模型、python 代码
|
19天前
|
编解码 JSON 自然语言处理
通义千问重磅开源Qwen2.5,性能超越Llama
击败Meta,阿里Qwen2.5再登全球开源大模型王座
826 14
|
14天前
|
人工智能 开发框架 Java
重磅发布!AI 驱动的 Java 开发框架:Spring AI Alibaba
随着生成式 AI 的快速发展,基于 AI 开发框架构建 AI 应用的诉求迅速增长,涌现出了包括 LangChain、LlamaIndex 等开发框架,但大部分框架只提供了 Python 语言的实现。但这些开发框架对于国内习惯了 Spring 开发范式的 Java 开发者而言,并非十分友好和丝滑。因此,我们基于 Spring AI 发布并快速演进 Spring AI Alibaba,通过提供一种方便的 API 抽象,帮助 Java 开发者简化 AI 应用的开发。同时,提供了完整的开源配套,包括可观测、网关、消息队列、配置中心等。
621 7
|
7天前
|
Docker 容器
Docker操作 (五)
Docker操作 (五)
170 69
|
7天前
|
Docker 容器
Docker操作 (三)
Docker操作 (三)
167 69
|
19天前
|
人工智能 自动驾驶 机器人
吴泳铭:AI最大的想象力不在手机屏幕,而是改变物理世界
过去22个月,AI发展速度超过任何历史时期,但我们依然还处于AGI变革的早期。生成式AI最大的想象力,绝不是在手机屏幕上做一两个新的超级app,而是接管数字世界,改变物理世界。
628 52
吴泳铭:AI最大的想象力不在手机屏幕,而是改变物理世界