dcLunatic's blog

not a in b与a not in b的思考

字数统计: 2.2k阅读时长: 9 min
2021/07/25 Share

问题

就一般工作中,我们会考虑将not a in b改写成a not in b的写法,但似乎是为什么呢?类似的,还有not a is ba is not b,如果就单单阅读角度考虑的话,确实a not in bb is not none更加的贴近于英语的语法,阅读起来会更加顺畅(但也看人)。但这两种写法,本质上究竟有什么区别呢?下面以not a is none以及a is not none举例。

分析及结果

最简单的,把他们的字节码dis出来就知道具体有什么区别了。

1
2
3
4
5
6
7
8
9
10
11
>>> import dis
>>> dis.dis(compile('a is not None', '', 'eval'))
1 0 LOAD_NAME 0 (a)
2 LOAD_CONST 0 (None)
4 COMPARE_OP 9 (is not)
6 RETURN_VALUE
>>> dis.dis(compile('not a is None', '', 'eval'))
1 0 LOAD_NAME 0 (a)
2 LOAD_CONST 0 (None)
4 COMPARE_OP 9 (is not)
6 RETURN_VALUE

一目了然,并没有什么实际的区别。所以a is not Nonenot a is None其实怎么用都无所谓,最多就是从阅读方面哪种优于哪种,没了。

思考

到这,貌似也没什么意思,不妨继续仔细再深究下,为什么会是这个结果?明明代码上是不一样,最后出来的字节码却是一摸一样的?相比之下,这个问题似乎更有意思。明显这里肯定是做了一些类似指令优化的东西(下边主要提这个),而指令优化的目的无非就是为了提高速度,进一步,联想到编译优化和运行优化。

优化源码分析

回到一开始,像python之类的编程语言,会先将代码编译成pyc文件,执行的时候,加载该pyc里边一系列的codeObject信息,然后在虚拟机里边跑起来。
而对应codeObject的信息上边也打印出来了,所以最主要的应该是生成pyc的过程,即“编译”中python做了点什么。

有其他语言经验的,其实这里很容易就可以一目了然了。

而”编译”代码的步骤,简单一点的就是生成ast,然后将遍历该ast,生成所谓的字节码。于是我们可以先看看xx is not in xxnot xx in xx的ast。

详细一点的呢,可以分词法分析(Lexing),解析(Parsing),编译(Compiling),Iterpreting(解释),但这不是重点,有兴趣的可自行了解ast相关内容

1
2
3
4
5
>>> import ast
>>> ast.dump(ast.parse('a is not None', '', 'eval'))
"Expression(body=Compare(left=Name(id='a', ctx=Load()), ops=[IsNot()], comparators=[NameConstant(value=None)]))"
>>> ast.dump(ast.parse('not a is None', '', 'eval'))
"Expression(body=UnaryOp(op=Not(), operand=Compare(left=Name(id='a', ctx=Load()), ops=[Is()], comparators=[NameConstant(value=None)])))"

可以看到,ast是不一样的,但字节码又是一样的,确实可能的,就是在这个过程中python又做了些什么,所以呢,可以简单看看python源码中生成字节码的部分。

有兴趣的,可以重点看看compile.c, parsemodule.c,ast.py之类的,也可以结合py的ast文档一起,这个Issue里边也提到了许多相关的内容,就不展开了。

根据上边的ast结果以及文档,可以很简单查到对应着操作符:cmpop = Eq | NotEq | Lt | LtE | Gt | GtE | Is | IsNot | In | NotIn

下边仅随手贴一点过程中的代码,至于更为详细具体的代码分析生成过程,亦或者一些consts之类的填充,可以看下这个Python int缓存那点事,或者自己慢慢从PyRun_FileExFlags()开撕。不了解字节码的,可以看这个Fun with Python bytecode

盘下来,解释执行的一些调用关系如下(就展开到代码优化),不想看的,可适当跳过。

1
2
3
4
5
6
7
8
9
10
11
PyRun_FileExFlags()
mod_ty *mod = PyParser_ASTFromFile() //把py源码转换成AST(Abstract Syntax Tree)
run_mod(mod, ...) //执行AST
co = PyAST_Compile(mod, ...) //将AST转换成CFG(Control Flow Graph) bytecode
PySymtable_Build() //创建符号表
co = compiler_mod() //编译ast为bytecode
co = assemble()
co = makecode()
bytecode = PyCode_Optimize() //最为关键的优化
PyEval_EvalCode(co, ...) //执行bytecode
PyEval_EvalCodeEx()

直接在源码里边搜某些函数的调用时,可能并不能直接搜到,这些函数调用时,都是通过宏展开的,如compile_visit_expr是通过VISIT(c, expr, s->.Assign.value);展开的…

假如我们把PyCode_Optimize()这一步注释掉,那么出来的字节码是长这样的(看懂这里每一行大概表达的含义,对下边分析优化过程有帮助)

1
2
3
4
1   0 LOAD_NAME                0 (a)
2 LOAD_CONST 0 (None)
4 COMPARE_OP 8 (is)
6 UNARY_NOT

有趣吧,所以所以这究竟是如何从这样子的,转成另外的样子呢?
跳到peephole.c模块中对应的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PyObject *
PyCode_Optimize(PyObject *code, PyObject* consts, PyObject *names,
PyObject *lineno_obj)
{
for (i=0 ; i<codelen ; i += CODESIZE(codestr[i])) {
reoptimize_current:
opcode = codestr[i];
switch (opcode){
/* not a is b --> a is not b
not a in b --> a not in b
not a is not b --> a is b
not a not in b --> a in b
*/
case COMPARE_OP:
j = GETARG(codestr, i);
if (j < 6 || j > 9 ||
codestr[i+3] != UNARY_NOT ||
!ISBASICBLOCK(blocks,i,4))
continue;
SETARG(codestr, i, (j^1));
codestr[i+3] = NOP;
break;
}
}
}

整个方法比较复杂,就简单贴关心的内容就好了。通过一个for去遍历整个codestr,遍历的过程是从第一个操作符开始,然后每次去该操作符及其参数所占位置,然后判断做一些优化处理,然后继续跳到下个操作符。

单独的把这个case拿出来分析,还有上边我们看到的字节码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1   0 LOAD_NAME                0 (a)
2 LOAD_CONST 0 (None)
4 COMPARE_OP 8 (is)
6 UNARY_NOT

case COMPARE_OP: // 来到比较操作符,也即位置4
j = GETARG(codestr, i); // 获取对应的参数 这里拿到的就是8
if (j < 6 || j > 9 || // 比较下该操作是不是in, not in, is, is not,更详细的可以查下cmp_op表,或者看下官方的dis文档。
codestr[i+3] != UNARY_NOT || // 往后下一个操作符是不是 NOT
!ISBASICBLOCK(blocks,i,4)) // 是不是特殊的基础代码块
continue;
// 拿not xx is xx举例,
// 那么此时j=8,要变成xx is not xx,那么只需要将j设置为9,
// 并且下个操作符设置为空即可(这里生成的Nop后边会移除)
SETARG(codestr, i, (j^1));
codestr[i+3] = NOP;
break;

然后对每个操作符进行分支处理,对于操作符COMPARE_OP,通过if语句,来判断操作数位置,以及下个操作符是否是否为not(这里优化的就是not相关的)或者是基础代码块。如果识别出是可优化内容,即not a is b, not a in b, not a is not b, not a not in b,那么重新设置一下操作符信息,然后再把哪个not对应的操作符设置为空。相当于就变成了a is not b,a not in b,a is b,a in b。优化结束,最终字节码就变成了。

1
2
3
1           0 LOAD_NAME                0 (a)
2 LOAD_CONST 0 (None)
4 COMPARE_OP 9 (is not)

最后对整个codestr都执行完优化后,再通过PyCode_New将所需的内容传递进去,继续去创建对对应的code对象,到这里之后,不妨还可以发现一些其他有趣的优化,如:

1
2
3
4
5
6
7
8
9
10
/* Replace UNARY_NOT POP_JUMP_IF_FALSE
with POP_JUMP_IF_TRUE */
/* Skip over LOAD_CONST trueconst
POP_JUMP_IF_FALSE xx. This improves
"while 1" performance. */
/* Try to fold tuples of constants (includes a case for lists
which are only used for "in" and "not in" tests).
Skip over BUILD_SEQN 1 UNPACK_SEQN 1.
Replace BUILD_SEQN 2 UNPACK_SEQN 2 with ROT2.
Replace BUILD_SEQN 3 UNPACK_SEQN 3 with ROT3 ROT2. */

编译优化与运行优化

到这,优化就完了,优化的最终目的,还是为了速度上的提升,而这方面,就不仅想到了java,论优化,还是java的优化多得更多。随手贴篇其他人的文章自行阅读,此时,不妨从另外一个角度出发,如果要再进一步的去优化使用的python,还可以从那些角度出发呢?本来想继续往后的,不过看到一篇挺全面,多角度的文章——基于python的opcode优化和模块按需加载机制研究,下面简单罗列一下。

  • 因为可以节省编译时间,这里有一篇非常详细的文章,作者在遗传编程领域工作,发现他们Python 程序的总运算时间中,有50%都被编译过程吃掉。于是作者深入到 bytecode 层次进行了小小改动,大幅削减了编译时间,把总的运算时间降至不足原先的一半。(有改进的潜力)
  • 优化方向
    • 从bytecode入手,对常出现的opcode合并重排
    • 从串行转并行入手(多线程、多进程、多核心)
    • 优化python的解释逻辑额
    • 指令预测等

其他阅读

  1. DTC vs ITC(Indirect-Threaded Code)相关

  2. PythonCodeObjectParser Demo

  3. Peephole optimization

  4. Python 控制字节码执行

  5. Disassembler for Python bytecode

  6. The Very High Level Layer¶

  7. Let’s Build A Simple Interpreter(非常建议)

原文作者:dcLunatic

原文链接:http://dclunatic.github.io/python%E4%B8%ADnot-in%E5%92%8Cnot-xx-in%E6%80%9D%E8%80%83.html

发表日期:July 25th 2021, 11:14:01 am

更新日期:July 25th 2021, 11:20:39 am

版权声明:转载的时候,记得注明来处

CATALOG
  1. 1. 问题
    1. 1.1. 分析及结果
  2. 2. 思考
    1. 2.1. 优化源码分析
    2. 2.2. 编译优化与运行优化
  3. 3. 其他阅读