如何高效開發端智慧演算法?MNN 工作臺 Python 除錯詳解

語言: CN / TW / HK

隨著移動網際網路的快速發展,人工智慧在移動端上的應用越來越廣泛,集團內端智慧在影象識別、影片檢測、資料計算等核心場景發揮著重要作用。而在開發階段,Python 毋庸置疑是演算法進行研發的首選語言。但在移動端上,進行演算法的部署、除錯、驗證,仍處在“刀耕火種”的時代,目前演算法主要通過在程式碼中插入日誌,驗證程式的執行邏輯和結果。

通過打日誌當然也能驗證結果和定位問題,但一旦工程稍微複雜點,生產效率會非常低。因此,在 MNN 工作臺之中(點選文末閱讀原文,前往 MNN 官網:www.mnn.zone 下載)嵌入了端側 Python 除錯能力。經常使用Python的同學一定熟悉pdb模組,它是Python官方標準庫提供的互動式程式碼偵錯程式,和任何一門語言提供的除錯能力一樣,pdb提供了原始碼行級別的設定斷點、單步執行等常規除錯能力,是Python開發的一個很重要的工具模組。

今天就讓我們來重點分析下官方pdb模組原始碼,看看其除錯功能的底層技術原理。

原理

從cpython原始碼中可以看到,pdb模組並非c實現的內建模組,而是純Python實現和封裝的模組。核心檔案是pdb.py,它繼承自bdb和cmd模組:

class Pdb(bdb.Bdb, cmd.Cmd):    ...

基本原理:利用cmd模組定義和實現一系列的除錯命令的互動式輸入,基於sys.settrace插樁跟蹤程式碼執行的棧幀,針對不同的除錯命令控制程式碼的執行和斷點狀態,並向控制檯輸出對應的資訊。

cmd模組主要是提供一個控制檯的命令互動能力,通過raw_input/readline這些阻塞的方法實現輸入等待,然後將命令交給子類處理決定是否繼續迴圈輸入下去,就和他主要的方法名runloop一樣。


cmd是一個常用的模組,並非為pdb專門設計的,pdb使用了cmd的框架從而實現了互動式自定義除錯。

bdb提供了除錯的核心框架,依賴sys.settrace進行程式碼的單步執行跟蹤,然後分發對應的事件(call/line/return/exception)交給子類(pdb)處理。bdb的核心邏輯在對於除錯命令的中斷控制,比如輸入一個單步執行的”s“命令,決定是否需要繼續跟蹤執行還是中斷等待互動輸入,中斷到哪一幀等。

基本流程

  • pdb啟動,當前frame繫結跟蹤函式trace_dispatch
def trace_dispatch(self, frame, event, arg):
     if self.quitting:
         return # None
     if event == 'line':
         return self.dispatch_line(frame)
     if event == 'call':
         return self.dispatch_call(frame, arg)
     if event == 'return':
         return self.dispatch_return(frame, arg)
     if event == 'exception':
     ...
  • 每一幀的不同事件的處理都會經過中斷控制邏輯,主要是stop_here(line事件還會經過break_here)函式,處理後決定程式碼是否中斷,需要中斷到哪一行
  • 如需要中斷,觸發子類方法user_#event,子類通過interaction實現棧幀資訊更新,並在控制檯列印對應的資訊,然後執行cmdloop讓控制檯處於等待互動輸入
def interaction(self, frame, traceback):
     self.setup(frame, traceback) # 當前棧、frame、local vars
     self.print_stack_entry(self.stack[self.curindex])
     self.cmdloop()
     self.forget()
  • 使用者輸入除錯命令如“next”並回車,首先會呼叫set_#命令,對stopframe、returnframe、stoplineno進行設定,它會影響中斷控制```stop_here``的邏輯,從而決定執行到下一幀的中斷結果.
def _set_stopinfo(self, stopframe, returnframe, stoplineno=0):
     self.stopframe = stopframe
     self.returnframe = returnframe
     self.quitting = 0
     # stoplineno >= 0 means: stop at line >= the stoplineno
     # stoplineno -1 means: don't stop at all
     self.stoplineno = stoplineno
  • 對於除錯過程控制類的命令,一般do_#命令都會返回1,這樣本次runloop立馬結束,下次執行到某一幀觸發中斷會再次啟動runloop(見步驟3);對於資訊獲取類的命令,do_#命令都沒有返回值,保持當前的中斷狀態。
  • 程式碼執行到下一幀,重複步驟3

中斷控制

中斷控制也就是對於不同的除錯命令輸入後,能讓程式碼執行到正確的位置停止,等待使用者輸入,比如輸入”s”控制檯就應該在下一個執行frame的程式碼處停止,而輸出“c”就需要執行到下一個打斷點的地方。中斷控制發生在sys.settrace的每一步跟蹤的中,是除錯執行的核心邏輯。

pdb中主要跟蹤了frame的四個事件:

  • line:同一個frame中的順序執行事件
  • call:發生函式呼叫,跳到下一級的frame中,在函式第一行產生call事件
  • return:函式執行完最後一行(line),發生結果返回,即將跳出當前frame回到上一級frame,在函式最後一行產生return事件
  • exception:函式執行中發生異常,在異常行產生exception事件,然後在該行返回(return事件),接下來一級一級向上在frame中產生exception和return事件,直到回到底層frame。

它們是程式碼跟蹤時的不同節點型別,pdb根據使用者輸入的除錯命令,在每一步frame跟蹤時都會進行中斷控制,決定接下來是否中斷,中斷到哪一行。中斷控制的主要方法是stop_here:

def stop_here(self, frame):
        # (CT) stopframe may now also be None, see dispatch_call.
        # (CT) the former test for None is therefore removed from here.
        if self.skip and \
               self.is_skipped_module(frame.f_globals.get('__name__')):
            return False


        # next
        if frame is self.stopframe:
            # stoplineno >= 0 means: stop at line >= the stoplineno
            # stoplineno -1 means: don't stop at all
            if self.stoplineno == -1:
                return False
            return frame.f_lineno >= self.stoplineno


        # step:當前只要追溯到botframe,就等待執行。
        while frame is not None and frame is not self.stopframe:
            if frame is self.botframe:
                return True
            frame = frame.f_back
        return False

除錯命令大體上分兩類:

  1. 過程控制:如setp、next、continue等這些執行後馬上進入下階段的程式碼執行
  2. 資訊獲取/設定:如args、p、list等獲取當前資訊的,也不會影響cmd狀態

下面重點講解幾個最常見的,用於過程控制的除錯命令中斷控制實現原理:

s(step)

1 命令定義

執行下一條命令,如果本句是函式呼叫,則 s 會執行到函式的第一句。

2 程式碼分析

pdb中實現邏輯為順序執行每一個幀frame並等待執行,它的執行粒度和settrace一樣。

def stop_here(self, frame):
        ...
        # stopframe為None
        if frame is self.stopframe:
            ...
        # 當前frame一定會追溯到botframe,返回true
        while frame is not None and frame is not self.stopframe:
            if frame is self.botframe:
                return True
            frame = frame.f_back
        return False

step會將stopframe設定為None,因此只要當前frame能向後一直追溯到底層frame(botframe),就表示可以等待執行了,也就是pdb處於互動等待狀態。

因為step的執行粒度和settrace一樣,所以執行到每一幀都會等待執行。

n(next)

1 命令定義

執行下一條語句,如果本句是函式呼叫,則執行函式,接著執行當前執行語句的下一條。

2 程式碼分析

pdb中實現邏輯為,執行至當前frame的下一次跟蹤中斷,但進入到下一個frame(函式呼叫)中不會中斷。

def stop_here(self, frame):
        ...
        # 如果frame還沒跳出stopframe,永遠返回true
        if frame is self.stopframe:
            if self.stoplineno == -1:
                return False
            return frame.f_lineno >= self.stoplineno


        # 如果frame跳出了stopframe,進入下一個frame,則執行不會中斷,一直到跳出到stopframe
        # 還有一種情況,如果在return事件中斷執行了next,下一次跟蹤在上一級frame中,此時上一級frame能跟蹤到botframe,中斷
        while frame is not None and frame is not self.stopframe:
            if frame is self.botframe:
                return True
            frame = frame.f_back
        return False

next會設定stopframe為當前frame,也就是除非在當前frame內,進入其他的frame都不會執行中斷。

c

1 命令定義

繼續執行,直到遇到下一條斷點

2 程式碼分析

stopframe設定為botframe,stoplineno設定為-1。stop_here總返回false,執行不會中斷,直到遇到斷點(break_here條件成立)

def stop_here(self, frame):        ...        # 如果在botframe中,stoplineno為-1返回false        if frame is self.stopframe:            if self.stoplineno == -1:                return False            return frame.f_lineno >= self.stoplineno        # 如果在非botframe中,會先追溯到stopframe,返回false        while frame is not None and frame is not self.stopframe:            if frame is self.botframe:                return True            frame = frame.f_back        return False

r(return)

1 命令定義

執行當前執行函式到結束。

2 程式碼分析

return命令僅在執行到frame結束(函式呼叫)時中斷,也就是遇到return事件時中斷。
pdb會設定stopframe為上一幀frame,returnframe為當前frame。如果是非return事件,stop_here永遠返回false,不會中斷;

def stop_here(self, frame):
        ...
        # 如果當前幀程式碼順序執行,下一個frame的lineno==stoplineno
        # 如果執行到for迴圈的最後一行,下一個frame(for迴圈第一行)的lineno<stoplineno,不會中斷。直到for迴圈執行結束,緊接著的下一行的lineno==stoplineno,執行中斷
        if frame is self.stopframe:
            if self.stoplineno == -1:
                return False
            return frame.f_lineno >= self.stoplineno


        # 如果在非botframe中,會先追溯到stopframe,返回false,同next
        while frame is not None and frame is not self.stopframe:
            if frame is self.botframe:
                return True
            frame = frame.f_back
        return False

如果是return事件,stop_here仍然返回false,但是returnframe為當前frame判斷成立,會執行中斷。

def dispatch_return(self, frame, arg):
        if self.stop_here(frame) or frame == self.returnframe:
            self.user_return(frame, arg)
            if self.quitting: raise BdbQuit
        return self.trace_dispatch

unt(until)

1 命令定義

執行到下一行,和next的區別就在於for迴圈只會跟蹤一次

2 程式碼分析

設定stopframe和returnframe為當前frame,stoplineno為當前lineno+1。

def stop_here(self, frame):
        ...
        # 如果當前幀程式碼順序執行,下一個frame的lineno==stoplineno
        # 如果執行到for迴圈的最後一行,下一個frame(for迴圈第一行)的lineno<stoplineno,不會中斷。直到for迴圈執行結束,緊接著的下一行的lineno==stoplineno,執行中斷
        if frame is self.stopframe:
            if self.stoplineno == -1:
                return False
            return frame.f_lineno >= self.stoplineno


        # 如果在非botframe中,會先追溯到stopframe,返回false,同next
        while frame is not None and frame is not self.stopframe:
            if frame is self.botframe:
                return True
            frame = frame.f_back
        return False

如果在當前frame中有for迴圈,只會從上向下執行一次。如果是函式返回return事件,下一個frame的lineno有可能小於stoplineno,所以把returnframe設定為當前frame,這樣函式執行就和next表現一樣了。

u(up)/ d(down)

1 命令定義

切換到上/下一個棧幀

2 程式碼分析

棧幀資訊

棧幀包含程式碼呼叫路徑上的每一級frame資訊,每次命令執行中斷都會重新整理,可以通過u/d命令上下切換frame。
棧幀獲取主要通過get_stack方法,第一個引數是frame,第二個引數是traceback object。traceback object是在exception事件產生的,exception事件會帶一個arg引數:

exc_type, exc_value, exc_traceback = arg
(<type 'exceptions.IOError'>, (2, 'No such file or directory', 'wdwrg'), <traceback object at 0x10bd08a70>)

traceback object有幾個常用的屬性:

  • tb_frame:當前exception發生在的frame
  • tb_lineno:當前exception發生在的frame的行號,即frame.tb_lineno
  • tb_next:指向堆疊下一級呼叫的exc_traceback(traceback object),如果是最頂層則為None

棧幀資訊由兩部分組成,frame的呼叫棧和異常棧(如有),順序為:botframe -> frame1 -> frame2 -> tb1 -> tb2(出錯tb)

def get_stack(self, f, t):
        stack = []
        if t and t.tb_frame is f:
            t = t.tb_next
       # frame呼叫棧,從底到頂
        while f is not None:
            stack.append((f, f.f_lineno))
            if f is self.botframe:
                break
            f = f.f_back
        stack.reverse()
        i = max(0, len(stack) - 1) 


        # 異常棧,從底到頂(出錯棧)
        while t is not None:
            stack.append((t.tb_frame, t.tb_lineno))
            t = t.tb_next


        if f is None:
            i = max(0, len(stack) - 1)
        return stack, i

pdb每次執行中斷都會更新呼叫的棧幀表,以及當前的棧幀資訊,堆疊切換隻要向上/下切換索引即可。

def setup(self, f, t):
        self.forget()
        self.stack, self.curindex = self.get_stack(f, t)
        self.curframe_locals = self.curframe.f_locals
        ...
...
def do_up(self, arg):
        if self.curindex == 0:
            print >>self.stdout, '*** Oldest frame'
        else:
            self.curindex = self.curindex - 1
            self.curframe = self.stack[self.curindex][0]
            self.curframe_locals = self.curframe.f_locals
            self.print_stack_entry(self.stack[self.curindex])
            self.lineno = None

b(break)

區別於過程控制的除錯命令,break命令用來設定斷點,不會馬上影響程式中斷狀態,但可能會影響後續的中斷。在line事件發生的時候,除了stop_here會增加break_here的條件判斷,設定斷點的實現比較簡單,這裡主要介紹對函式設定斷點的時候,是怎麼讓程式碼執行到函式第一行中斷的。

設定斷點時,斷點的lineno為了函式的第一行:

# 函式斷點示例:break func
def do_break(self, arg, temporary = 0):
        ...
        if hasattr(func, 'im_func'):
                        func = func.im_func


                        funcname = code.co_name
                        lineno = code.co_firstlineno
                        filename = code.co_filename

當line事件執行到函式的第一行程式碼時,這一行沒有主動設定過斷點,但是函式第一行co_firstlineno命中斷點,所以會繼續判斷斷點有效性。

def break_here(self, frame):
        ...
        lineno = frame.f_lineno
        if not lineno in self.breaks[filename]:
            lineno = frame.f_code.co_firstlineno
            if not lineno in self.breaks[filename]:
                return False


        # flag says ok to delete temp. bp
        (bp, flag) = effective(filename, lineno, frame)

斷點的有效性判斷通過effective方法,其中處理了ignore、enabled這些配置,對函式斷點的有效性判斷通過checkfuncname方法:

def checkfuncname(b, frame):
    """Check whether we should break here because of `b.funcname`."""
    ...


    # Breakpoint set via function name.
    ...


    # We are in the right frame.
    if not b.func_first_executable_line:
        # The function is entered for the 1st time.
        b.func_first_executable_line = frame.f_lineno


    if  b.func_first_executable_line != frame.f_lineno:
        # But we are not at the first line number: don't break.
        return False
    return True

在line事件在函式第一行發生時,func_first_executable_line還沒有,於是設定為當前行號,並且斷點生效,因此函式執行到第一行中斷。接下來line到行數的後面行時,因為func_first_executable_line已經有值,並且肯定不等於當前行號,所以break_here判斷為無效,不會中斷。

例項分析

以下結合一個很簡單的Python程式碼除錯的例子,複習下上述命令的實現原理:

在控制檯中,命令列執行快照:

命令列中執行python test.py,Python程式碼實際是從第一行開始執行的,但因為pdb.set_trace()是在__main__中呼叫的,所以實際是從set_trace的下一行才掛載到pdb的跟蹤函式,開始frame的中斷控制。

這段Python程式碼執行會經過經過3個frame:

  1. 底層根frame0,即__main__所在的frame0,其中包含一斷for迴圈程式碼,frame0的back frame為None
  2. 第二層frame1,進入func方法所在的frame1,frame1的back frame為frame0
  3. 頂層frame2,進入add方法所在的frame2,frame2的back frame為frame1

除錯過程:

  1. 跟蹤__main__所在的frame(根frame0),在20行觸發line事件
  2. 使用者輸入unt命令回車,frame0在21行觸發line事件,行號等於上一次跟蹤行號+1,stop_here成立,中斷等待
  3. 使用者輸入unt命令回車,同2,在22行中斷
  4. 使用者輸入unt命令回車,程式碼跟蹤至frame0在20行觸發line事件,行號小於上一次跟蹤行號+1(23),stop_here不成立,繼續執行
  5. 在24行觸發line事件,行號大於上一次跟蹤行號+1(23),stop_here成立,中斷等待
  6. 使用者輸入s命令回車,程式碼跟蹤至frame1在12行觸發call事件,step執行粒度和sys.settrace一樣,在12行中斷等待
  7. 使用者設定add函式斷點,斷點列表中會加入add函式的第一行(第7行)的斷點
  8. 使用者輸入c命令回車,stop_here總返回false,繼續跟蹤執行直到在第8行觸發line事件,雖然第8行不再斷點列表中,但當前函式幀firstlineno在,並且有效,所以在第8行中斷等待
  9. 使用者輸入r命令回車,後面的line事件處理中stop_here都返回false,直到在第10行觸發return事件,此時returnframe為當前frame,在10行中斷等待
  10. 使用者輸入up命令,棧幀向前切換索引,回到上一幀frame1,也就是第13行func中呼叫add的地方
  11. 使用者輸入down命令,棧幀向前後切換索引,回到當前幀
  12. 使用者輸入n命令,執行至下一次跟蹤14行(line事件),這一次跟蹤在frame1上,能追溯到botframe,所以在14行中斷
  13. 使用者輸入n命令,執行至下一次跟蹤14行(return事件),還在當前frame1中,中斷
  14. 使用者輸入n命令,執行至下一次跟蹤24行(return事件),這一次跟蹤就是botframe(frame0),中斷
  15. 使用者輸入n命令,frame0執行結束。

小結

Python標準庫提供的pdb的實現並不複雜,本文對原始碼中的核心的邏輯做了講解,如果你瞭解其原理,也可以自己定製或重寫一個Python偵錯程式。事實上,業界的很多通用IDE如pycharm、vscode等都沒有使用標準的pdb,他們開發了自己的Python偵錯程式來更好的適配IDE。不過了解pdb原理,在pdb上改寫和定製偵錯程式來滿足除錯需求,也是一種成本低而有效的方式。

MNN工作臺對端側的除錯能力也是基於原生pdb實現的,並且支援阿里巴巴集團內端計算的各種研發場景,對演算法的研發部署都有很大的效率提升。點選閱讀原文,前往 www.mnn.zone 下載 MNN 工作臺趕快體驗吧。

關注我們,每週 3 篇移動技術實踐&乾貨給你思考!