天天看點

Python3 cpython優化 實作解釋器并行關于位元組跳動終端技術團隊

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等各終端都有深入研究。