Skip to content

Conversation

@SigureMo
Copy link
Member

@SigureMo SigureMo commented Dec 8, 2023

PR types

Bug fixes

PR changes

Others

Description

3.11 在进行 PRECALL 的 BUILTIN 指令特化时会假设下一个字节码一定是 CALL,但我们 COPY 后生成的字节码不是的,就会跳转到错误的位置,因此需要删除这个多余的 PRECALL

这个 PR 发版拉分支后再合入,发版前优先合入 #59855

另外清理一下 #59855 为保发版的 trick 以及 #59816 错误定位问题的修复方案

PCard-66972

@paddle-bot
Copy link

paddle-bot bot commented Dec 8, 2023

你的PR提交成功,感谢你对开源项目的贡献!
请关注后续CI自动化测试结果,详情请参考Paddle-CI手册
Your PR has been submitted. Thanks for your contribution!
Please wait for the result of CI firstly. See Paddle CI Manual for details.

@SigureMo SigureMo changed the title [SOT][3.11] Remove trailing PRECALL to avoid wrong specialization [WIP][SOT][3.11] Remove trailing PRECALL to avoid wrong specialization Dec 8, 2023
gouzil
gouzil previously approved these changes Dec 9, 2023
@SigureMo SigureMo changed the title [WIP][SOT][3.11] Remove trailing PRECALL to avoid wrong specialization [SOT][3.11] Remove trailing PRECALL to avoid wrong specialization Dec 12, 2023
@SigureMo
Copy link
Member Author

贴一下问题分析记录:

现象

在使用 MIN_GRAPH_SIZE=10 的情况下我们现在会 COPY 原有代码,只有在此时生成的代码会出现问题,且很容易出现问题:

import paddle


def foo():
    print(1)


for i in range(10):
    paddle.jit.to_static(foo)()

这是一个非常简单的包含打断且会触发 MIN_GRAPH_SIZE=10 的 case,其生成代码包含两个 RESUME,在第 4 次循环就会挂掉。

Python 每次 RESUME 都会 warmup 一次,在 warmup 第 8 次的时候会触发 quicken,将字节码替换为超指令或者自适应指令,而这里明显是触发 quicken 的时候或者之后挂掉了(这一点通过 pass 删除一个 RESUME 后会在第 8 次才挂得到了验证)

通过将 print替换为等价的能够触发 breakgraph 的非 builtin API 无法复现问题大概率猜出是 CALL 相关的特化指令出了问题,应该是其中存在某种假设,而我们生成的代码并不满足。

编译 Python 调试

现象基本清楚,应该就是自适应首次特化时候就出了问题,那么接下来就是编译 Python 打印调试信息。

编译后的 debug 版本马上就有更加清晰的报错:

Fatal Python error: _PyEval_EvalFrameDefault: We've reached an unreachable state. Anything is possible.
The limits were in our heads all along. Follow your dreams.
https://xkcd.com/2200
Python runtime state: initialized

明显是我们生成的代码使 VM 达到了一个理应不可达的状态,在 Py_UNREACHABLE 打印信息后发现挂在了 cevalCACHE 字节码,但按理说 ceval 不应该执行到 CACHE 字节码,CACHE 是用来存储 inline 的自适应/特化指令信息的,比如计数器,正常来说不可能指向这条字节码,比如原始指令里都会使用 JUMPBY(INLINE_CACHE_ENTRIES_BINARY_OP) 这种方式跳过 CACHE 部分,而走后面的字节码,那问题就是哪条字节码挂掉了。
因为这时候才刚刚 warmup 为自适应指令,因此在全部自适应指令里加 log,并在 DISPATCH_GOTO 里加入 log 以确定发生问题的字节码:

#define DISPATCH_GOTO() \
do { \
    if (should_print) { \
        printf("[opcode_targets] %d\n", opcode); \
    } \
    goto *opcode_targets[opcode]; \
} while (0);
#else

...

TARGET(STORE_SUBSCR_ADAPTIVE) {
    if (should_print) {
        printf("STORE_SUBSCR_ADAPTIVE %d\n", oparg);
    }
[opcode_targets] 151
[opcode_targets] 116
[opcode_targets] 100
[opcode_targets] 166
[opcode_targets] 171
[opcode_targets] 125
[opcode_targets] 151
[opcode_targets] 47
LOAD_GLOBAL_ADAPTIVE 1
[opcode_targets] 48
[opcode_targets] 100
[opcode_targets] 64
PRECALL_ADAPTIVE 1
[opcode_targets] 67
1
[opcode_targets] 0
[BEGIN] unreachable in CACHE
Fatal Python error: _PyEval_EvalFrameDefault: We've reached an unreachable state. Anything is possible.
The limits were in our heads all along. Follow your dreams.
https://xkcd.com/2200
Python runtime state: initialized

通过 log 可以发现是跑到了 PRECALL_ADAPTIVE 之后走了一个指令 67(PRECALL_BUILTIN_FAST_WITH_KEYWORDS),之后就执行了 0(CACHE),果然是 CALL 相关字节码特化为 BUILTIN 时候出的问题!

也就是 PRECALL_BUILTIN_FAST_WITH_KEYWORDS 应当是做了某种错误的优化,直接将 next_instr 设为了某个 CACHE
我们打印一下生成的字节码,看一下具体发生问题的位置,在 dis 时添加 show_caches=Trueadaptive=True 可现实更多细节:

[transform] NewCode: #foo_84ae0
  4           0 RESUME                   0
              2 LOAD_GLOBAL              3 (NULL + paddle_set_eval_frame_fn)
              4 CACHE                    0
              6 CACHE                    0
              8 CACHE                    0
             10 CACHE                    0
             12 CACHE                    0
             14 LOAD_CONST               0 (None)
             16 PRECALL                  1
             18 CACHE                    0
             20 CALL                     1
             22 CACHE                    0
             24 CACHE                    0
             26 CACHE                    0
             28 CACHE                    0
             30 STORE_FAST               0 (___old_eval_frame)

  5          32 RESUME                   0                   <----- 这里是第二个 RESUME,问题只能出在这后面
             34 LOAD_GLOBAL              1 (NULL + print)    <----- 上面的 LOAD_GLOBAL_ADAPTIVE 1
             36 CACHE                    0
             38 CACHE                    0
             40 CACHE                    0
             42 CACHE                    0
             44 CACHE                    0
             46 LOAD_CONST               1 (1)
             48 PRECALL                  1                   <----- 上面的 PRECALL_ADAPTIVE 1,应该就是这里挂掉了
             50 CACHE                    0
             52 NOP
             54 LOAD_GLOBAL              3 (NULL + paddle_set_eval_frame_fn)
             56 CACHE                    0
             58 CACHE                    0
             60 CACHE                    0
             62 CACHE                    0
             64 CACHE                    0
             66 LOAD_FAST                0 (___old_eval_frame)
             68 PRECALL                  1
             70 CACHE                    0
             72 CALL                     1
             74 CACHE                    0
             76 CACHE                    0
             78 CACHE                    0
             80 CACHE                    0
             82 POP_TOP
             84 STORE_FAST               1 (__start_compile_saved_orig_0)
             86 STORE_FAST               2 (__start_compile_saved_orig_1)
             88 STORE_FAST               3 (__start_compile_saved_orig_2)
             90 LOAD_GLOBAL              4 (___null_var)
             92 CACHE                    0
             94 CACHE                    0
             96 CACHE                    0
             98 CACHE                    0
            100 CACHE                    0
            102 LOAD_FAST                2 (__start_compile_saved_orig_1)
            104 LOAD_FAST                1 (__start_compile_saved_orig_0)
            106 CALL                     1
            108 CACHE                    0
            110 CACHE                    0
            112 CACHE                    0
            114 CACHE                    0
            116 LOAD_GLOBAL              7 (NULL + $resume_0@foo_af1a0)
            118 CACHE                    0
            120 CACHE                    0
            122 CACHE                    0
            124 CACHE                    0
            126 CACHE                    0
            128 SWAP                     2
            130 SWAP                     3
            132 PRECALL                  1
            134 CACHE                    0
            136 CALL                     1
            138 CACHE                    0
            140 CACHE                    0
            142 CACHE                    0
            144 CACHE                    0
            146 RETURN_VALUE
            148 POP_TOP
            150 LOAD_CONST               0 (None)
            152 RETURN_VALUE

现在已经明确是 PRECALL_BUILTIN_FAST_WITH_KEYWORDS 设置了错误的 next_instr,那么接下来便查看具体是哪里设置了错误的 next_instr

        TARGET(PRECALL_BUILTIN_FAST_WITH_KEYWORDS) {
            assert(cframe.use_tracing == 0);
            /* Builtin METH_FASTCALL | METH_KEYWORDS functions */
            int is_meth = is_method(stack_pointer, oparg);
            int total_args = oparg + is_meth;
            PyObject *callable = PEEK(total_args + 1);
            DEOPT_IF(!PyCFunction_CheckExact(callable), PRECALL);
            DEOPT_IF(PyCFunction_GET_FLAGS(callable) !=
                (METH_FASTCALL | METH_KEYWORDS), PRECALL);
            STAT_INC(PRECALL, hit);
            SKIP_CALL();
            STACK_SHRINK(total_args);
            /* res = func(self, args, nargs, kwnames) */
            _PyCFunctionFastWithKeywords cfunc =
                (_PyCFunctionFastWithKeywords)(void(*)(void))
                PyCFunction_GET_FUNCTION(callable);
            PyObject *res = cfunc(
                PyCFunction_GET_SELF(callable),
                stack_pointer,
                total_args - KWNAMES_LEN(),
                call_shape.kwnames
            );
            assert((res != NULL) ^ (_PyErr_Occurred(tstate) != NULL));
            call_shape.kwnames = NULL;

            /* Free the arguments. */
            for (int i = 0; i < total_args; i++) {
                Py_DECREF(stack_pointer[i]);
            }
            STACK_SHRINK(2-is_meth);
            PUSH(res);
            Py_DECREF(callable);
            if (res == NULL) {
                goto error;
            }
            CHECK_EVAL_BREAKER();
            DISPATCH();
        }

直接看并没有发现明显的 next_instr 设置,但全局搜索 next_instr 后发现其中的 SKIP_CALL 其实是修改了的:

// Skip from a PRECALL over a CALL to the next instruction:
#define SKIP_CALL() \
    JUMPBY(INLINE_CACHE_ENTRIES_PRECALL + 1 + INLINE_CACHE_ENTRIES_CALL)

啊……它直接不仅把自己的 PRECALL 后面的 CACHE 跳过了,还跳过了 CALL 自身和 CALLCACHE,但是,我们发生问题的 PRECALL 后面并没有 CALL,这在我们生成代码里是一个错误的假设!

                实际的                                   Python 以为的
             48 PRECALL                  1              一样
             50 CACHE                    0              一样
             52 NOP                                     CALL
             54 LOAD_GLOBAL              3              CACHE
             56 CACHE                    0              CACHE
             58 CACHE                    0              CACHE
             60 CACHE                    0              CACHE
             62 CACHE                    0              然后就跳到这里了,执行这个可不是就挂了么
             64 CACHE                    0

解决方案

这里单独出现的 PRECALL 是引发报错的关键,可以在打断时最后一条字节码为 PRECALL 的情况下直接删掉即可,因为打断后的 gen_call_function 会同时生成 PRECALLCALL
保险起见是否还应该在 PASS 进行检查或修改,如果出现 PRECALL,后续一定应该是 CALL,否则至少报错,不然调试起来太花时间
但发版建议 3.11 还是不加该代码 copy 机制,因为目前并不能保证问题全部解决,谁知道 Python 还在特化指令里做了些什么其他假设,相关修改在拉分支后再尝试

Copy link
Contributor

@2742195759 2742195759 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Contributor

@XieYunshen XieYunshen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM
单测名称变更

@feifei-111 feifei-111 merged commit c546fe6 into PaddlePaddle:develop Dec 14, 2023
@feifei-111 feifei-111 deleted the sot/remove-trailing-precall branch December 14, 2023 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants