得物自研DScript2.0腳本能力從0到1演進
一、前言
在高并發推薦引擎場景中,C++的極致性能往往以開發效率為妥協,尤其在業務頻繁迭代時,C++的開發效率流程成為顯著瓶頸。傳統嵌入式腳本(如Lua)雖支持動態加載,但其與C++的交互成本(如虛擬棧數據中轉、類型轉換)仍會帶來額外性能損耗。
為此,我們探索設計DScript2.0——一種與C++內存布局及調用約定深度兼容的動態腳本語言,通過自研編譯器實現即時編譯與無縫嵌入,嘗試在保留腳本靈活性的同時,盡可能貼近C++的原生性能,為性能與效率的平衡提供了輕量化解決方案。
二、動態腳本在引擎中的引用
C++引擎的迭代效率瓶頸
在搜推引擎中的實踐中,出于對高并發場景下極致性能的追求,使用C++進行引擎自研成為了一種業界常態。
眾所周知,C++通過開放底層控制權限(如內存分配,指令優化等),提升了可達的性能上限,但這種提升伴隨了大量底層細節的處理,消耗了更多的開發時間,追求性能優先的同時,卻又限制了開發效率。
我們希望能夠在保持性能的同時,提升引擎的開發效率。
利用嵌入式腳本提升迭代效率
我們的目標是尋求一種平衡性能與迭代效率的方案,一種主流方案是在C++中嵌入腳本語言。例如,在游戲引擎和Nginx開發中集成Lua,在C/C++代碼中實現性能需求,結合腳本代碼中實現控制邏輯,從而提升開發效率。
嵌入式腳本對迭代效率的提升
- 支持動態加載,無需編譯部署。
- 無需C/C++經驗,腳本學習成本低,提升參與迭代的人力總量。
引擎的迭代拆解
- 引擎內部的技術性迭代
- 業務側的需求支持
業務側的需求非常適合引入嵌入式腳本,實現對易變需求的自迭代,提升開發效率,這也是一種業界主流方案。例如,一些搜索中臺中,對于相關性和粗排邏輯封裝為插件,業務側的算法工程師使用Lua開發計算邏輯,可以極大地提升迭代效率。
嵌入式腳本的額外性能開銷
在引擎中嵌入腳本,雖然可以提升迭代效率,但并非全無代價,高階語言與低階語言的交互存在著額外的性能開銷。
例如,Lua和C++的交互機制基于Lua提供的虛擬棧來實現,這個棧是兩者進行數據交換的核心通道。
使用虛擬棧實現語言交互存在額外的開銷,包括但不限于壓棧和彈棧操作、棧空間管理、類型檢查和轉換、復雜數據結構的處理等。
圖片
更加極致的方案
基于以上的瓶頸,我們期望一種更加極致的方案,實現性能與效率的平衡。
嵌入式腳本的額外性能開銷
(主要源于兩種語言在ABI層面的不一致)
- 函數調用約定不一致,需要一個虛擬棧進行中轉。
- 數據類型內存布局不一致,需要額外的檢查和轉換。
一個直觀的解決方案就是我們設計一種編程語言,在底層實現上與C++具有一致內存布局與調用約定,從而消除額外的轉換開銷。
同時,這種編程語言可以在C++嵌入,也支持即時編譯,提升效率的同時,也擁有與原生C++近似的執行性能。
以上是我們規劃DScript2.0項目初衷。
二、DScript2.0的編譯器實現
語法設計
DScript2.0被設計為一種輕量級面向過程的編程語言,同時它也是靜態類型的編譯語言。
在語法支持上,包含了基礎數據類型、變量、運算符、控制流和函數,額外支持了與C++的語言互操作。
數據類型 | int,long,bool,float,double,void |
變量 | 自定義變量,隱式類型轉換。 |
C++變量:支持訪問和操作外部注冊的C++變量,支持C++的結構體部分操作。 | |
運算符 | 算術運算符:+,-,*,/,% |
關系運算符:==,!=,>=,>,<=,< | |
邏輯運算符:!,&&,|| | |
賦值運算符:=,+= | |
自增自減運算符:++i,--i | |
控制流 | 分支語句:if (...) else if (...) else |
循環語句: for循環 | |
函數 | 自定義函數:基礎類型值傳遞,對象類型引用傳遞。 |
C++API:支持調用外部注冊的C++函數。 |
淺析編譯器架構
(編譯器的三段結構)
一個完整的編譯器通常由三個主要部分組成:前端、優化器和后端。
- 前端:負責詞法分析、語法分析、語義分析、生成中間代碼。
- 優化器(中端):負責對中間代碼進行優化。
- 后端:負責將中間代碼轉換成目標機器的的機器碼。
基于LLVM實現DScript2.0編譯器
圖片
LLVM 是一個模塊化且高度可重用的編譯器基礎設施項目。它提供了前端、優化器和后端工具鏈,已支持多種編程語言和平臺。LLVM具有跨平臺性,允許開發者靈活定制編譯流程,提供高級優化能力,支持即時編譯,被廣泛用于編譯器開發、虛擬機和代碼分析工具場景。
※ 采用LLVM實現DScript2.0的優勢
- 提升開發效率:LLVM的前端、中端和后端采用了模塊化設計,每個部分都可以獨立替換或擴展,這種靈活性使得 LLVM 非常適合定制編譯器,我們可以復用LLVM的中端與后端,專注于前端開發,減少開發成本。
- 支持高級優化:LLVM 提供了一套強大的優化工具,能夠對代碼進行靜態和動態優化。這些優化不僅能夠提高代碼的執行效率,還可以減少代碼體積。這是DScript2.0理論上可能提供接近原生C++性能的關鍵因素之一。
- 支持即時編譯:LLVM 支持即時編譯(JIT),通過 JIT 編譯,LLVM 能夠在運行時生成和執行代碼,大大提升了執行效率。通過運行時進行編譯后運行,這是DScript2.0理論上可能提供接近原生C++性能的關鍵因素之二。支持在線的即時編譯能力,同時也是算子開發與分發效率的保障。
DScript2.0編譯器架構
圖片
- DScript2.0編譯器同樣包含前端、中端、后端三部分,前端能力自研,優化器和后端基于LLVM的Pass和JIT實現。
- 編譯器最終輸出為x86_64平臺的可執行二進制,以JIT實例的方式常駐內存,通過入口函數地址執行。
- 編譯器支持注入C++類型與函數參與編譯,實現DScript2.0對C++的調用。
編譯器前端實現
前端的實現流程
編譯器前端的任務是將源碼轉換為優化器可處理的中間代碼,這個轉換的流程通常包含4個步驟:
- 詞法分析
- 語法分析
- 語義分析
- 中間代碼生成
(編譯器前端架構)
詞法分析
原理:源代碼是一堆連續的字符,計算機要先識別出這些字符組成的基本單元,才能進一步理解代碼含義。就像讀句子先得認出單詞一樣,這是理解程序的第一步。詞法分析的本質是將代碼的字符流,轉換為更易處理的token流。
輸入與輸出:字符流->記號流(Tokens)。
※ 詞法分析器
DScript2.0中了使用Flex,可以根據自定義的正則表達式規則,自動生成詞法分析的掃描器,減少手工編寫詞法分析器的工作量。
Flex工作流程
圖片
Flex語法
在Flex的定義文件中包含三部分:
- 定義段:包含頭文件和全局變量,如輸入和輸出流的定義。
- 規則段:由模式和對應的動作組成。當掃描器匹配到模式時,執行對應的動作。例如,匹配到"int"字符串時,將其識別為INT標識。
- 用戶代碼段:通常可以在此區域定義 main() 函數,它調用 yylex() ,啟動詞法分析過程。
示例:
/* 定義段段開始 */
/* 引入的c/c++代碼 */
%{
#include <string>
%}
/* 正則表達式的宏定義 */
LineTerminator \n|\r|\r\n
WhiteSpace [ \t\f]|{LineTerminator}
Identifier [a-zA-Z_][a-zA-Z0-9_]*
/* 定義段結束 */
%%
/* 規則段開始 */
/* 規則:正則表達式 { return 傳遞給語法分析器的記號類型 } */
"int" { return INT; }
"float" { return FLOAT; }
"void" { return VOID; }
{Identifier} {
yylval.identifier = new std::string(yytext);
return IDENTIFIER;
}
{LineTerminator} {}
{WhiteSpace} {}
<<EOF>> {
return END;
}
/* 規則段結束 */
%%
/* 用戶代碼段開始 */
/* 用戶代碼段結束 */匹配規則
- 最長匹配:當多個規則可匹配時,Flex選擇最長匹配的詞素。
- 最先定義:若多個規則長度相同,則選擇最先定義的規則。
語法分析
原理:語法分析的原理是根據上下文無關文法(CFG)對輸入的 tokens 序列進行分析,驗證其是否符合某種語言的語法規則,并構建對應的抽象語法樹。其核心在于建立程序的分層邏輯結構,并確保這種結構符合語法約束。
輸入與輸出:記號流->抽象語法樹(AST)。
由語法分析原理拆分
- 結構驗證:檢查記號流的排列是否符合語法規則,DScript2.0的語法規則由上下文無關文法(CFG)描述,驗證算法采用了自底向上的LR算法。
// 示例:分支語法規則:if (conditon) { stmts }
// 符合語法規則
if (a < 1) {
// 不符合語法規則
if a < 1 {- 層次構建:將線性的記號流轉換為樹狀或嵌套的語法結構,以抽象語法樹為例:
int func(int a) {
int b = a + 1;
return b;
}FunctionDefinition
├── ReturnType: int
├── FunctionName: func
├── Parameters
│ └── Parameter
│ ├── Type: int
│ └── Name: a
└── Body
├── VariableDeclaration
│ ├── Type: int
│ ├── Name: b
│ └── InitialValue
│ └── +
│ ├── Variable: a
│ └── Constant: 1
└── ReturnStatement
└── Variable: b※ 上下文無關文法(CFG)
上下文無關文法(CFG) 是編譯器語法分析的核心工具,用于形式化描述編程語言的語法結構。
其核心要素包括:
- 終結符(如標識符、運算符),對應詞法分析的 Token,不可再分解。
- 非終結符(如表達式、語句),需通過產生式規則展開為終結符或其他非終結符。
- 產生式規則(如 E → E + T) ,定義語法結構的生成方式。
- 起始符號(如 Program ),代表語法分析的入口。
產生式規則定義示例:
/* 局部變量聲明 -> 類型 變量聲明 */
/* 例如 int a = 1 */
/* Type對應int */
/* Variable_Declartor對應a = 1 */
Local_Variable_Declartor ->
Type Variable_Declartor;
/* 變量聲明 -> 變量ID 或 變量ID = 變量初始化 */
Variable_Declartor ->
Variable_ID
| Variable_ID EQ Variable_Initializer;
/* 變量ID -> 標識符 */
Variable_ID -> IDENTIFIER;
/* 變量初始化 -> 任意表達式 */
Variable_initializer -> expression;示例中根據形式化的語法,描述了變量定義和變量初始化規則。
示例中包含4條產生式規則:
- 局部變量聲明規則
- 變量聲明表達式規則
- 變量ID規則
- 變量初始化規則
終止符:
- Type對應一個C++的TypeNode
- IDENTIFIER對應詞法定義的Token
※ 語法分析器
語法分析器采用Bison來實現,Bison可以與Flex進行協作,將詞法分析器生成的記號序列解析為語法樹,供編譯器進一步處理。
通過與 Flex 協同工作,Bison 可以自動化地處理復雜的語法分析任務,使編譯器的開發更加高效和靈活。
語義分析
原理:通過遍歷抽象語法樹,實現上下文相關的文法檢查,對程序的類型、作用域和標識符等進行詳細檢查,確保程序在邏輯上符合編程語言的規則,同時生成中間表示代碼,作為優化器或后端的輸入。
輸入與輸出:抽象語法樹->中間代碼。
語法分析與語義分析的區別:
- 輸出目標不同:語法分析的主要任務是將記號流轉換為結構化信息,語義分析是將結構化信息翻譯為優化器可以處理的中間表示語言。
- 語法正確的語句,語義未必正確:
- 例如,有函數原型 void echo(int a) ,在調用時 int b = echo("a") ,這是符合語法的,但不符合語義。
- 再比如,語言要求使用變量前先定義,在未定義變量 a 的前提下,執行賦值 a = 1; ,這樣也是符合語法但不符合語義的。
※ 語義分析的主要任務
符號表管理
- 作用域解析:追蹤變量/函數的作用域(如塊級作用域、全局作用域)。
- 符號綁定:將標識符與其聲明關聯(如變量類型、函數簽名)。
- 重復定義檢查:禁止同一作用域內同名符號的重復聲明。
類型系統校驗
- 類型推斷與檢查:驗證表達式和操作的合法性,如 int a = "str"; 類型不匹配。
- 隱式類型轉換:處理類型提升,如 int + float 自動轉為浮點運算。
- 函數簽名匹配:檢查實參與形參的個數、類型一致性。
控制流合法性
- 語句上下文檢查:確保 break 僅在循環內、 return 與函數返回類型一致。
- 可達性分析:檢測不可達代碼(如 return 后的語句)。
常量表達式求值
- 優化常量計算(如 const x = 2 + 3*4; 直接計算為 14 )。
- 用于數組長度、條件編譯等需編譯期確定值的場景。
※ 中間代碼生成
中間代碼的生成流程是通過遞歸遍歷AST完成的,將語義檢查無誤的邏輯,轉換為中間表示語言,這是編譯器前端工作的最后一步。
DScript2.0中使用了LLVM IR作為中間代碼語言,它介于高級語言和目標代碼之間,既能表達高級語言的抽象概念,又能適應底層機器代碼的生成需求。
LLVM IR提供了豐富的指令集,涵蓋了從基本運算到復雜控制流、內存操作、同步操作等各種編程需求。
LLVM IR指令集示例
指令種類 | 指令/作用 |
算術和位操作指令 |
|
內存訪問指令 |
|
比較指令 |
|
控制流指令 | br: 條件或無條件分支 |
函數管理指令 |
|
轉換示例:
int func(int a) {
int b = a + 1;
return b;
}(源代碼)
; 函數定義: 函數名為 func,返回類型為 i32(32位整數),參數為 i32 類型的 a
define i32 @func(i32 %a) {
entry:
; 定義局部變量 b,并將其初始化為 a + 1 的結果
%b = add i32 %a, 1
; 返回 b 的值
ret i32 %b
}(與之對應的LLVM的中間代碼)
編譯器中端:中間代碼優化
圖片
- 在DScript2.0中,優化器是通過復用LLVM的中端優化能力來實現的,通過一系列LLVM預置的優化遍(Pass),對程序生成的中間代碼進行優化,以提高代碼的性能。
- 中端的輸出為優化過后的IR指令,這些IR指令需要提供給后端進行編譯。
在LLVM中,優化遍是指按照一定順序執行的一個或多個優化算法。
以下是一些常用的優化算法:
數據流分析 | 死代碼消除 (DCE) | 通過數據流分析,LLVM 能夠精確地識別和刪除這些無用的指令。 |
全局值編號(GVN) | 檢測并消除等價的冗余表達式,減少重復計算。 | |
循環優化 | 循環展開 (Loop Unrolling) | 通過展開循環體中的指令,減少循環控制的開銷,并增加指令級并行性。 |
循環分割 (Loop Split) | 將復雜的循環拆分為多個更簡單的循環,以便更好地優化每個循環。 | |
循環不變代碼外提 (LICM) | 將循環中不變的計算移出循環體,從而減少不必要的重復計算。 | |
控制流優化 | 條件合并 (Conditional Merging) | 合并控制流中多余的條件判斷,從而簡化分支結構。 |
跳轉線程化 (Jump Threading) | 在控制流圖中,將多個條件判斷組合為一個單一的跳轉,以減少不必要的分支。 | |
尾調用優化 (TCO) | 優化遞歸函數調用,使得尾遞歸調用能夠直接重用當前棧幀,從而避免棧溢出。 | |
內存訪問優化 | 內存別名分析 (Alias Analysis) | 確定不同指針是否指向相同的內存位置,從而幫助優化器在內存訪問上進行優化,如消除冗余的內存加載和存儲操作。 |
堆棧分配優化 (Stack Allocation Optimization) | 通過分析棧上變量的生命周期,減少不必要的內存分配和釋放,或者將棧分配的變量優化到寄存器中。 |
編譯器后端:即時編譯
圖片
DScript2.0 使用 LLVM 的 ORC JIT 作為即時編譯器的實現,支持在程序運行時編譯腳本,并通過查找函數地址的方式執行腳本。
采用即時編譯器的優勢:
- 避免了開發調試過程中,頻繁的啟停程序,提升迭代效率。
- 且經過編譯的代碼,在執行時能夠顯著提升運行性能。
語言互操作性
語言互操作性是指不同編程語言能夠相互調用、協同工作的能力。通過這種能力,開發者可以在同一項目中結合多種語言的優勢。
例如,C++ 與 Lua 的結合是就互操作的經典場景,常見于游戲開發、搜推引擎、嵌入式系統等領域。
在我們的需求中,要支持動態腳本訪問引擎的表列資源,就需要DScript2.0也能具備與C++交互操作的能力。
DScript2.0與C++的語言互操作性體現在
- DScript2.0可以調用C++的函數,并向C++傳遞數據。
- C++可以調用DScript2.0的函數,并向DScript腳本傳遞數據。
- DScript2.0可以訪問和操作C++傳遞的基礎類型和結構體類型變量。
調試能力
DScript2.0基于GDB實現了基本的調試能力:
- 支持通過Attach進程進行實時調試
- 支持在coredump中保留棧信息
調試能力的實現主要基于GDB的通用調試接口,在編譯DScript2.0源碼時,生成調試信息,插入到LLVM IR的元數據中,然后通過JIT的監聽器掛載GDB調試接口,并注入調試信息,最終實現調試能力。
圖片
異常處理
DScript2.0中也實現了異常處理能力,主要包括了硬件異常的主動防御和跨C++與DScript2.0邊界的異常傳播。
硬件異常防御
程序異常可以劃分為硬件異常和主動異常:
- 硬件異常是底層不可控錯誤,硬件異常的處理需依賴信號鉤子或語言運行時封裝。
典型例子:
- 段錯誤(SIGSEGV):非法內存訪問
- 浮點運算錯誤(SIGFPE):如整數除零或浮點運算異常
- 非法指令(SIGILL):執行未定義的機器指令
- 總線錯誤 (SIGBUS):如未對齊的內存訪問
- 主動異常是代碼邏輯的一部分,用于可控的錯誤處理與資源管理,主動異常由開發者顯式拋出,也可由語言運行時隱式轉換。
※ 硬件異常的主動防御
DScript2.0在語言層面上,對代碼引發的硬件異常進行了主動防御。實現上,是在語義分析階段,對中間代碼添加防御邏輯,防御策略則采用了可被捕獲的主動異常拋出。
例如下圖所示,在編譯階段,編譯器對于結構體指針進行了空引用檢查邏輯,將硬件異常轉換為了主動異常,而主動異常可以通過捕獲來進行處理,避免了進程崩潰。

跨語言邊界傳播
因為DScript2.0的語言互操作性特性,會涉及到C++與DScript2.0的函數互相調用(如下圖所示),就會涉及到異常處理時,異常在C++和DScript2.0之間傳播,即所謂跨語言邊界。
DScript2.0主要實現了如下的異常傳播機制:
- 腳本調用 C++ 函數時若拋出異常,在腳本端不進行捕獲,但支持異常傳播到C++端,同時正常完成棧回退。
- C++ 調用腳本函數時若拋出異常,可以在 C++ 端捕獲。

四、DScript2.0在線開發工作流
圖片
DScript2.0通過平臺化實現了在線開發的工作流:
- 引擎集成:以SDK方式與引擎進行集成,提供在線編譯和加載的能力。
- 在線IDE:實現編輯、編譯的在線開發環境。
- 在線工作流:通過平臺化支持腳本的在線分發與管理。
五、總結
DScript2.0的實踐為推薦引擎的敏捷迭代探索了一條新路徑。通過編譯器架構與C++底層機制的高度兼容設計,它在降低跨語言交互成本、支持動態加載等方面展現出潛力,同時保持了接近原生C++的運行時性能。
其即時編譯能力與在線開發流程,使業務團隊能獨立完成邏輯更新,減少對傳統C++開發中編譯部署的依賴,初步驗證了兼顧性能與效率的可能性。
未來,我們計劃進一步完善調試工具鏈與異常處理機制,并探索其在混合語言場景下的擴展性,以更輕量的方式推動引擎架構的持續優化。



































