Python3 cpython優化 實現解釋器並行

本文介紹了對cpython解釋器的並行優化,使其支持真正的多解釋器並行執行的解決方案。

作者:字節跳動終端技術——謝俊逸

背景

在業務場景中,我們通過cpython執行算法包,由於cpython的實現,在一個進程內,無法利用CPU的多個核心去同時執行算法包。對此,我們決定優化cpython,目標是讓cpython高完成度的支持並行,大幅度的提高單個進程內Python算法包的執行效率。

在2020年,我們完成了對cpython的並行執行改造,是目前業界首個cpython3的高完成度同時兼容Python C API的並行實現。

  • 性能

    • 單線程性能劣化7.7%
    • 多線程基本無鎖搶佔,多開一個線程減少44%的執行時間。
    • 並行執行對總執行時間有大幅度的優化
  • 通過了cpython的單元測試
  • 在線上已經全量使用

cpython痛, GIL

cpython是python官方的解釋器實現。在cpython中,GIL,用於保護對Python對象的訪問,從而防止多個線程同時執行Python字節碼。GIL防止出現競爭情況並確保線程安全。 因爲GIL的存在,cpython 是無法真正的並行執行python字節碼的. GIL雖然限制了python的並行,但是因爲cpython的代碼沒有考慮到並行執行的場景,充滿着各種各樣的共享變量,改動複雜度太高,官方一直沒有移除GIL。

挑戰

在Python開源的20年裏,Python 因爲GIL(全局鎖)不能並行。目前主流實現Python並行的兩種技術路線,但是一直沒有高完成度的解決方案(高性能,兼容所有開源feature, API穩定)。主要是因爲:

  1. 直接去除GIL 解釋器需要加許多細粒度的鎖,影響單線程的執行性能,慢兩倍。

Back in the days of Python 1.5, Greg Stein actually implemented a comprehensive patch set (the “free threading” patches) that removed the GIL and replaced it with fine-grained locking. Unfortunately, even on Windows (where locks are very efficient) this ran ordinary Python code about twice as slow as the interpreter using the GIL. On Linux the performance loss was even worse because pthread locks aren’t as efficient.

  1. 解釋器狀態隔離 解釋器內部的實現充滿了各種全局狀態,改造繁瑣,工作量大。

It has been suggested that the GIL should be a per-interpreter-state lock rather than truly global; interpreters then wouldn’t be able to share objects. Unfortunately, this isn’t likely to happen either. It would be a tremendous amount of work, because many object implementations currently have global state. For example, small integers and short strings are cached; these caches would have to be moved to the interpreter state. Other object types have their own free list; these free lists would have to be moved to the interpreter state. And so on.

這個思路開源有一個項目在做 multi-core-python,但是目前已經擱置了。目前只能運行非常簡單的算術運算的demo。對Type和許多模塊的並行執行問題並沒有處理,無法在實際場景中使用。

新架構-多解釋器架構

爲了實現最佳的執行性能,我們參考multi-core-python,在cpython3.10實現了一個高完成度的並行實現。

  • 從全局解釋器狀態 轉換爲 每個解釋器結構持有自己的運行狀態(獨立的GIL,各種執行狀態)。
  • 支持並行,解釋器狀態隔離,並行執行性能不受解釋器個數的影響(解釋器間基本沒有鎖相互搶佔)
  • 通過線程的Thread Specific Data獲取Python解釋器狀態。

在這套新架構下,Python的解釋器相互隔離,不共享GIL,可以並行執行。充分利用現代CPU的多核性能。大大減少了業務算法代碼的執行時間。

共享變量的隔離

解釋器執行中使用了很多共享的變量,他們普遍以全局變量的形式存在.多個解釋器運行時,會同時對這些共享變量進行讀寫操作,線程不安全。

cpython內部的主要共享變量:3.10待處理的共享變量。大概有1000個...需要處理,工作量非常之大。

  • free lists

    • MemoryError
    • asynchronous generator
    • context
    • dict
    • float
    • frame
    • list
    • slice
  • singletons

    • small integer ([-5; 256] range)
    • empty bytes string singleton
    • empty Unicode string singleton
    • empty tuple singleton
    • single byte character (b’\x00’ to b’\xFF’)
    • single Unicode character (U+0000-U+00FF range)
  • cache

    • slide cache
    • method cache
    • bigint cache
    • ...
  • interned strings
  • PyUnicode_FromId static strings
  • ....

如何讓每個解釋器獨有這些變量呢?

cpython是c語言實現的,在c中,我們一般會通過 參數中傳遞 interpreter_state 結構體指針來保存屬於一個解釋器的成員變量。這種改法也是性能上最好的改法。但是如果這樣改,那麼所有使用interpreter_state的函數都需要修改函數簽名。從工程角度上是幾乎無法實現的。

只能換種方法,我們可以將interpreter_state存放到thread specific data中。interpreter執行時,通過thread specific key獲取到 interpreter_state.這樣就可以通過thread specific的API,獲取到執行狀態,並且不用修改函數的簽名。

static inline PyInterpreterState* _PyInterpreterState_GET(void) {
    PyThreadState *tstate = _PyThreadState_GET();
#ifdef Py_DEBUG
    _Py_EnsureTstateNotNULL(tstate);
#endif
    return tstate->interp;
}

共享變量變爲解釋器單獨持有 我們將所有的共享變量存放到 interpreter_state裏。

    /* Small integers are preallocated in this array so that they
       can be shared.
       The integers that are preallocated are those in the range
       -_PY_NSMALLNEGINTS (inclusive) to _PY_NSMALLPOSINTS (not inclusive).
    */
    PyLongObject* small_ints[_PY_NSMALLNEGINTS + _PY_NSMALLPOSINTS];
    struct _Py_bytes_state bytes;
    struct _Py_unicode_state unicode;
    struct _Py_float_state float_state;
    /* Using a cache is very effective since typically only a single slice is
       created and then deleted again. */
    PySliceObject *slice_cache;

    struct _Py_tuple_state tuple;
    struct _Py_list_state list;
    struct _Py_dict_state dict_state;
    struct _Py_frame_state frame;
    struct _Py_async_gen_state async_gen;
    struct _Py_context_state context;
    struct _Py_exc_state exc_state;

    struct ast_state ast;
    struct type_cache type_cache;
#ifndef PY_NO_SHORT_FLOAT_REPR
    struct _PyDtoa_Bigint *dtoa_freelist[_PyDtoa_Kmax + 1];
#endif

通過 _PyInterpreterState_GET 快速訪問。 例如

/* Get Bigint freelist from interpreter  */
static Bigint **
get_freelist(void) {
    PyInterpreterState *interp = _PyInterpreterState_GET();
    return interp->dtoa_freelist;
} 

注意,將全局變量改爲thread specific data是有性能影響的,不過只要控制該API調用的次數,性能影響還是可以接受的。 我們在cpython3.10已有改動的的基礎上,解決了各種各樣的共享變量問題,3.10待處理的共享變量

Type變量共享的處理,API兼容性及解決方案

目前cpython3.x 暴露了PyType_xxx 類型變量在API中。這些全局類型變量被第三方擴展代碼以&PyType_xxx的方式引用。如果將Type隔離到子解釋器中,勢必造成不兼容的問題。這也是官方改動停滯的原因,這個問題無法以合理改動的方式出現在python3中。只能等到python4修改API之後改掉。

我們通過另外一種方式快速的改掉了這個問題。

Type是共享變量會導致以下的問題

  1. Type Object的 Ref count被頻繁修改,線程不安全
  2. Type Object 成員變量被修改,線程不安全。

改法:

  1. immortal type object.
  2. 使用頻率低的不安全處加鎖。
  3. 高頻使用的場景,使用的成員變量設置爲immortal object.

    1. 針對python的描述符機制,對實際使用時,類型的property,函數,classmethod,staticmethod,doc生成的描述符也設置成immortal object.

這樣會導致Type和成員變量會內存泄漏。不過由於cpython有module的緩存機制,不清理緩存時,便沒有問題。

pymalloc內存池共享處理

我們使用了mimalloc替代pymalloc內存池,在優化1%-2%性能的同時,也不需要額外處理pymalloc。

subinterperter 能力補全

官方master最新代碼 subinterpreter 模塊只提供了interp_run_string可以執行code_string. 出於體積和安全方面的考慮,我們已經刪除了python動態執行code_string的功能。 我們給subinterpreter模塊添加了兩個額外的能力

  1. interp_call_file 調用執行python pyc文件
  2. interp_call_function 執行任意函數

subinterpreter 執行模型

python中,我們執行代碼默認運行的是main interpreter, 我們也可以創建的sub interpreter執行代碼,

interp = _xxsubinterpreters.create()
result = _xxsubinterpreters.interp_call_function(*args, **kwargs)

這裏值得注意的是,我們是在 main interpreter 創建 sub interpreter, 隨後在sub interpreter 執行,最後把結果返回到main interpreter. 這裏看似簡單,但是做了很多事情。

  1. main interpreter 將參數傳遞到 sub interpreter
  2. 線程切換到 sub interpreter的 interpreter_state。獲取並轉換參數
  3. sub interpreter 解釋執行代碼
  4. 獲取返回值,切換到main interpreter
  5. 轉換返回值
  6. 異常處理

這裏有兩個複雜的地方:

  1. interpreter state 狀態的切換
  2. interpreter 數據的傳遞

interpreter state 狀態的切換

interp = _xxsubinterpreters.create()
result = _xxsubinterpreters.interp_call_function(*args, **kwargs)

我們可以分解爲

# Running In thread 11:
# main interpreter:
# 現在 thread specific 設置的 interpreter state 是 main interpreter的
do some things ... 
create subinterpreter ...
interp_call_function ...
# thread specific 設置 interpreter state 爲 sub interpreter state
# sub interpreter: 
do some thins ...
call function ...
get result ...
# 現在 thread specific 設置 interpreter state 爲 main interpreter state
get return result ...

interpreter 數據的傳遞

因爲我們解釋器的執行狀態是隔離的,在main interpreter 中創建的 Python Object是無法在 sub interpreter 使用的. 我們需要:

  1. 獲取 main interpreter 的 PyObject 關鍵數據
  2. 存放在 一塊內存中
  3. 在sub interpreter 中根據該數據重新創建 PyObject

interpreter 狀態的切換 & 數據的傳遞 的實現可以參考以下示例 ...

static PyObject *
_call_function_in_interpreter(PyObject *self, PyInterpreterState *interp, _sharedns *args_shared, _sharedns *kwargs_shared)
{
    PyObject *result = NULL;
    PyObject *exctype = NULL;
    PyObject *excval = NULL;
    PyObject *tb = NULL;
    _sharedns *result_shread = _sharedns_new(1);

#ifdef EXPERIMENTAL_ISOLATED_SUBINTERPRETERS
    // Switch to interpreter.
    PyThreadState *new_tstate = PyInterpreterState_ThreadHead(interp);
    PyThreadState *save1 = PyEval_SaveThread();

    (void)PyThreadState_Swap(new_tstate);
#else
    // Switch to interpreter.
    PyThreadState *save_tstate = NULL;
    if (interp != PyInterpreterState_Get()) {
        // XXX Using the  head  thread isn't strictly correct.
        PyThreadState *tstate = PyInterpreterState_ThreadHead(interp);
        // XXX Possible GILState issues?
        save_tstate = PyThreadState_Swap(tstate);
    }
#endif
    
    PyObject *module_name = _PyCrossInterpreterData_NewObject(&args_shared->items[0].data);
    PyObject *function_name = _PyCrossInterpreterData_NewObject(&args_shared->items[1].data);

    ...
    
    PyObject *module = PyImport_ImportModule(PyUnicode_AsUTF8(module_name));
    PyObject *function = PyObject_GetAttr(module, function_name);
    
    result = PyObject_Call(function, args, kwargs);

    ...

#ifdef EXPERIMENTAL_ISOLATED_SUBINTERPRETERS
    // Switch back.
    PyEval_RestoreThread(save1);
#else
    // Switch back.
    if (save_tstate != NULL) {
        PyThreadState_Swap(save_tstate);
    }
#endif
    
    if (result) {
        result = _PyCrossInterpreterData_NewObject(&result_shread->items[0].data);
        _sharedns_free(result_shread);
    }
    
    return result;
}

實現子解釋器池

我們已經實現了內部的隔離執行環境,但是這是API比較低級,需要封裝一些高度抽象的API,提高子解釋器並行的易用能力。

interp = _xxsubinterpreters.create()
result = _xxsubinterpreters.interp_call_function(*args, **kwargs)

這裏我們參考了,python concurrent庫提供的 thread pool, process pool, futures的實現,自己實現了 subinterpreter pool. 通過concurrent.futures 模塊提供異步執行回調高層接口。

executer = concurrent.futures.SubInterpreterPoolExecutor(max_workers)
future = executer.submit(_xxsubinterpreters.call_function, module_name, func_name, *args, **kwargs)
future.context = context
future.add_done_callback(executeDoneCallBack)

我們內部是這樣實現的: 繼承 concurrent 提供的 Executor 基類

class SubInterpreterPoolExecutor(_base.Executor):

SubInterpreterPool 初始化時創建線程,並且每個線程創建一個 sub interpreter

interp = _xxsubinterpreters.create()
t = threading.Thread(name=thread_name, target=_worker,
                     args=(interp, 
                           weakref.ref(self, weakref_cb),
                           self._work_queue,
                           self._initializer,
                           self._initargs))

線程 worker 接收參數,並使用 interp 執行

result = self.fn(self.interp ,*self.args, **self.kwargs)

實現外部調度模塊

針對sub interpreter的改動較大,存在兩個隱患

  1. 代碼可能存在兼容性問題,第三方C/C++ Extension 實現存在全局狀態變量,非線程安全。
  2. python存在着極少的一些模塊.sub interpreter無法使用。例如process

我們希望能統一對外的接口,讓使用者不需要關注這些細節,我們自動的切換調用方式。自動選擇在主解釋器使用(兼容性好,穩定)還是子解釋器(支持並行,性能佳)

我們提供了C和python的實現,方便業務方在各種場景使用,這裏介紹下python實現的簡化版代碼。

在bddispatch.py 中,抽象了調用方式,提供統一的執行接口,統一處理異常和返回結果。 bddispatch.py

def executeFunc(module_name, func_name, context=None, use_main_interp=True, *args, **kwargs):
    print( submit call  , module_name,  . , func_name)
    if use_main_interp == True:
        result = None
        exception = None
        try:
            m = __import__(module_name)
            f = getattr(m, func_name)
            r = f(*args, **kwargs)
            result = r
        except:
            exception = traceback.format_exc()
        singletonExecutorCallback(result, exception, context)

    else:
        future = singletonExecutor.submit(_xxsubinterpreters.call_function, module_name, func_name, *args, **kwargs)
        future.context = context
        future.add_done_callback(executeDoneCallBack)


def executeDoneCallBack(future):
    r = future.result()
    e = future.exception()
    singletonExecutorCallback(r, e, future.context)

直接綁定到子解釋器執行

對於性能要求高的場景,通過上述的方式,由主解釋器調用子解釋器去執行任務會增加性能損耗。 這裏我們提供了一些CAPI, 讓直接內嵌cpython的使用方通過C API直接綁定某個解釋器執行。

class GILGuard {
public:
    GILGuard() {
        inter_ = BDPythonVMDispatchGetInterperter();
        if (inter_ == PyInterpreterState_Main()) {
            printf( Ensure on main interpreter: %p\n , inter_);
        } else {
            printf( Ensure on sub interpreter: %p\n , inter_);
        }
        gil_ = PyGILState_EnsureWithInterpreterState(inter_);
        
    }
    
    ~GILGuard() {
        if (inter_ == PyInterpreterState_Main()) {
            printf( Release on main interpreter: %p\n , inter_);
        } else {
            printf( Release on sub interpreter: %p\n , inter_);
        }
        PyGILState_Release(gil_);
    }
    
private:
    PyInterpreterState *inter_;
    PyGILState_STATE gil_;
};

// 這樣就可以自動綁定到一個解釋器直接執行
- (void)testNumpy {
    GILGuard gil_guard;
    BDPythonVMRun(....);
}

關於字節跳動終端技術團隊

字節跳動終端技術團隊(Client Infrastructure)是大前端基礎技術的全球化研發團隊(分別在北京、上海、杭州、深圳、廣州、新加坡和美國山景城設有研發團隊),負責整個字節跳動的大前端基礎設施建設,提升公司全產品線的性能、穩定性和工程效率;支持的產品包括但不限於抖音、今日頭條、西瓜視頻、飛書、番茄小說等,在移動端、Web、Desktop等各終端都有深入研究。

團隊目前招聘 python解釋器優化方向的實習生,工作內容主要爲優化cpython解釋器,優化cpythonJIT(自研),優化cpython常用三方庫。歡迎聯繫 微信: beyourselfyii。郵箱: [email protected]


🔥 火山引擎 APMPlus 應用性能監控是火山引擎應用開發套件 MARS 下的性能監控產品。我們通過先進的數據採集與監控技術,爲企業提供全鏈路的應用性能監控服務,助力企業提升異常問題排查與解決的效率。目前我們面向中小企業特別推出「APMPlus 應用性能監控企業助力行動」,爲中小企業提供應用性能監控免費資源包。現在申請,有機會獲得60天免費性能監控服務,最高可享6000萬條事件量。

👉 點擊這裏,立即申請

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章