後端與委託¶
目標受眾:對將自己的編譯器和硬體整合到 ExecuTorch 中感興趣的供應商、後端委託開發者
後端委託是後端處理和執行 PyTorch 程式的入口點,以便利用專用後端和硬體的效能及效率優勢,同時仍為 PyTorch 使用者提供接近 PyTorch 執行時的體驗。
後端介面:概述¶
從高層來看,後端的入口點由兩個元件定義
用於表示程式的 IR:Edge 方言(透過
to_edgeAPI 生成)後端需要實現的幾個介面
預先 (Ahead-of-Time, AOT)
程式預處理(例如,預先編譯、轉換、最佳化等)。
執行時
程式初始化(例如,執行時編譯)。
程式執行。
(可選)程式銷燬(例如,釋放後端擁有的資源)。
委託後端實現由以下部分組成:
預先預處理介面
執行時初始化和執行介面
圖示如下
圖 1. 後端介面的高層入口點,包括預先和執行時。
後端介面:預先預處理¶
後端主要需要實現兩個預先入口點:partition 和 preprocess。
partitioner 是由後端實現的演算法,用於標記要下層處理到後端的節點。to_backend API 將應用分割槽演算法,並將每個由相互連線的標記節點組成的子圖下層處理到目標後端。每個子圖將被髮送到後端提供的 preprocess 部分,編譯成二進位制塊(binary blob)。
在分割槽過程中,不允許 exported_program 修改程式,它應該為每個節點應用標籤。PartitionResult 包含帶有標記的匯出程式和分割槽標籤字典,供 to_backend 查詢標籤並連結到 backend_id 和 compile_spec。
def partition(
exported_program: ExportedProgram,
) -> PartitionResult:
在預處理過程中,後端會接收一個 Edge 方言程式和一份指定編譯所需值的編譯規範列表,並預期返回一個已編譯的二進位制塊(compiled blob),即包含期望在後端執行的程式的二進位制檔案。在序列化期間,該已編譯的二進位制塊將作為 .pte 檔案的一部分被序列化,並直接載入到裝置上。此過程的 API 是
def preprocess(
edge_program: ExportedProgram,
compile_specs: List[CompileSpec],
) -> PreprocessResult:
此處 實現了一個預處理函式演示。該演示迴圈遍歷 edge_program 圖模組中的節點,並將 add、mul 和 sin 指令序列化為字串,該字串稍後在執行時解析並執行。
圖示如下
圖 2. 圖經過分割槽,每個子圖將傳送到預處理部分。
後端介面:執行時初始化和執行¶
在執行時,來自 preprocess 函式的已編譯二進位制塊將被載入並直接傳遞給後端的自定義 init 函式。該函式負責進一步處理已編譯單元,以及執行任何後端初始化。然後將呼叫後端的自定義 execute 函式來執行由 init 生成的控制代碼。最後,如果某些後端需要銷燬,則後端可以實現一個 destroy 函式,該函式將在程式生命週期結束時被呼叫。
// Runtime check
ET_NODISCARD bool is_available();
// Runtime initialization
ET_NODISCARD virtual Result<DelegateHandle*> init(
BackendInitContext& context,
FreeableBuffer* processed,
ArrayRef<CompileSpec> compile_specs);
// Runtime execution
ET_NODISCARD virtual Error execute(
BackendExecutionContext& context,
DelegateHandle* handle,
EValue** args);
// [optional] Runtime destroy. Destroy the resource held by the backend
virtual void destroy(ET_UNUSED DelegateHandle* handle);
圖示如下
圖 3. 標準 ExecuTorch 執行時與後端入口點的關係。
為了使後端對 ExecuTorch 執行時可用,必須透過 register_backend API 進行註冊
ET_NODISCARD Error register_backend(const Backend& backend);
後端的靜態註冊,即在庫初始化或載入時,可以透過以下方式實現
namespace {
auto cls = BackendWithCompiler();
Backend backend{"BackendWithCompilerDemo", &cls};
static auto success_with_compiler = register_backend(backend);
} // namespace
開發者工具整合:可除錯性¶
提供一致的除錯體驗,無論是執行時故障還是效能分析,都非常重要。ExecuTorch 為此目的採用了原生的開發者工具,該工具透過除錯控制代碼(debug handles)實現程式指令與原始 PyTorch 程式碼的關聯。您可以在此處閱讀更多資訊。
委託程式或子圖對於 ExecuTorch 執行時來說是不可見的,它們表現為一個特殊的 call_delegate 指令,該指令要求相應的後端處理子圖或程式的執行。由於後端委託的不透明性,原生的開發者工具無法檢視委託的程式。因此,與非委託執行相比,委託執行的除錯(功能性或效能)體驗會顯著受損。
為了為使用者提供一致的除錯體驗,無論模型是否使用委託,開發者工具都提供了一個介面來關聯委託(子)圖與原始(子)圖。開發者工具透過除錯控制代碼對映(debug handles map)來實現這一點,該對映允許委託生成可以與委託消費的原始(子)圖相關聯的內部控制代碼。然後在執行時,後端開發者可以使用內部控制代碼報告錯誤或效能分析資訊,這些資訊將透過除錯控制代碼對映映射回原始(子)圖。更多資訊請參閱委託除錯。
透過利用除錯識別符號(debug identifier),後端開發者可以將除錯資訊嵌入到委託的二進位制塊(delegated blob)中
這樣,在執行階段,藉助除錯識別符號,後端開發者可以將委託內部的失敗指令關聯回原始 Python 程式碼的確切行。
常見問題¶
1. 如何在 backend.preprocess 中獲取資料?
正在預處理的圖模組是一個提升後的圖(lifted graph),這意味著靜態資料(如權重和偏差)作為輸入提供給圖。但是,我們可以透過匯出程式(exported program)預先訪問這些權重和偏差。要從給定節點訪問這些引數,可以使用 torch/_export/utils.py 中提供的函式 get_params。
2. 如何將資料(如權重/偏差)嵌入到後端?
後端通常有一些方法來最佳化常量資料。在這種情況下,我們需要在分割槽器中標記作為狀態的佔位符節點,並且在 backend.preprocess 期間,我們可以按照第一個問題中的描述獲取權重。
3. 如何使用特定後端在 Python 中執行下層處理後的模組?
我們尚未新增此功能,但這在計劃中!
4. 我們是否應該期望在 edge 方言程式中看到 get_attr 節點?
get_attr 節點僅用於控制流或委託的子模組。它不包含任何資料。
5. 我們可以委託給多個後端嗎?
可以!有兩種方法可以實現這一點
選項 1: 對不同的後端多次執行 to_backend
如果我們有兩個後端:backend_1 和 backend_2,並且它們有各自的分割槽器:backend_1_parititioner 和 backend_2_partitioner,我們可以這樣執行:
# Will first lower nodes to backend_1 depending on the backend_1_parititioner depending on partitioner algorithm
exported_program_backend_1 = to_backend(exported_program, backend_1_parititioner())
# For the rest of nodes, they will be lowered to backend_2 depending on backend_2_parititioner
exported_program_backend_1_and_2 = to_backend(exported_program_backend_1, backend_2_parititioner())
更具體的示例可以在此處找到。在此示例中,qnnpack 是一個後端,xnnpack 是另一個後端。我們尚未開源這兩個後端委託,因此此示例無法直接執行。它可以作為參考,瞭解如何實現。
此選項易於嘗試,因為通常所有後端都會實現自己的分割槽器。然而,如果我們改變 to_backend 呼叫的順序,此選項可能會得到不同的結果。如果我們想更好地控制節點(例如它們應該去哪個後端),選項 2 更好。
選項 2: 擁有一個為不同後端分割槽的分割槽器
另一個選項是建立一個自定義分割槽器,例如分割槽器 backend_1_2_partitioner,並在分割槽器邏輯內部,
class Backend_1_2_Partitioner(Partitioner):
"""
Partitions all add/mul nodes regardless of order for Backend2
"""
def __init__(self) -> None:
self.delegation_spec_1 = DelegationSpec("Backend1", [])
self.delegation_spec_2 = DelegationSpec("Backend2", [])
self.partition_tags = {}
def partition(
self, exported_program: ExportedProgram
) -> ExportedProgram:
# Tag all nodes in the first partiton to backend 1
node_to_backend_1 = ... # some logic to select the nodes from the graph
delegation_tag = f"backend2_tag{partitioner_1.id}"
node.meta["delegation_tag"] = delegation_tag
self.partition_tags[delegation_tag] = self.delegation_spec_1
# Tag all nodes in the first partiton to backend 2
node_to_backend_2 = ... # some logic to select the nodes from the graph
delegation_tag = f"backend2_tag{partitioner_2.id}"
node.meta["delegation_tag"] = delegation_tag
self.partition_tags[delegation_tag] = self.delegation_spec_2
return exported_program
6. 有沒有簡單的方法來編寫分割槽器?
我們此處提供了一些輔助分割槽器,以便輕鬆地從分解的運算元中查詢節點。
7. 如何將節點連結回原始碼? 我們提供了一個輔助函式
from executorch.exir.print_program import inspect_node
print(inspect_node(graph, node))
它將突出顯示圖中的節點並指向原始碼,示例輸出如下所示
_param_constant1 error_msg: Here is the node in the graph module:
graph():
%arg0_1 : [num_users=1] = placeholder[target=arg0_1]
%_param_constant0 : [num_users=1] = get_attr[target=_param_constant0]
--> %_param_constant1 : [num_users=1] = get_attr[target=_param_constant1]
%aten_convolution_default : [num_users=2] = call_function[target=executorch.exir.dialects.edge._ops.aten.convolution.default](args = (%arg0_1, %_param_constant0, %_param_constant1, [1, 1], [0, 0], [1, 1], False, [0, 0], 1), kwargs = {})
%_param_constant2 : [num_users=1] = get_attr[target=_param_constant2]
%_param_constant3 : [num_users=1] = get_attr[target=_param_constant3]
%aten_convolution_default_1 : [num_users=1] = call_function[target=executorch.exir.dialects.edge._ops.aten.convolution.default](args = (%aten_convolution_default, %_param_constant2, %_param_constant3, [1, 1], [0, 0], [1, 1], False, [0, 0], 1), kwargs = {})
%aten_add_tensor : [num_users=1] = call_function[target=executorch.exir.dialects.edge._ops.aten.add.Tensor](args = (%aten_convolution_default, %aten_convolution_default_1), kwargs = {})
%_param_constant4 : [num_users=1] = get_attr[target=_param_constant4]
%_param_constant5 : [num_users=1] = get_attr[target=_param_constant5]
%aten_convolution_default_2 : [num_users=1] = call_function[target=executorch.exir.dialects.edge._ops.aten.convolution.default](args = (%aten_add_tensor, %_param_constant4, %_param_constant5, [1, 1], [0, 0], [1, 1], False, [0, 0], 1), kwargs = {})
%aten_gelu_default : [num_users=1] = call_function[target=executorch.exir.dialects.edge._ops.aten.gelu.default](args = (%aten_convolution_default_2,), kwargs = {})
return [aten_gelu_default]
This node _param_constant1 has metadata of:
The node stacktrace:
Traceback (most recent call last):
File "/tmp/ipykernel_1204253/3382880687.py", line 7, in forward
return self.test_model(x)
File "/mnt/xarfuse/uid-25337/7b86ad0c-seed-nspid4026532987_cgpid2707357-ns-4026532984/torch/nn/modules/module.py", line 1528, in _call_impl
return forward_call(*args, **kwargs)
File "/tmp/ipykernel_1204253/712280972.py", line 10, in forward
a = self.conv1(x)