程式碼生成技術初探(一)表示式編譯
程式碼生成(Code Generation)技術廣泛應用於現代的資料系統中。程式碼生成是將使用者輸入的表示式、查詢、儲存過程等現場編譯成二進位制程式碼再執行,相比解釋執行的方式,執行效率要高得多。尤其是對於計算密集型查詢、或頻繁重複使用的計算過程,運用程式碼生成技術能達到數十倍的效能提升。
當我們談論程式碼生成時我們在談論什麼
很多大資料產品都將程式碼生成技術作為賣點,然而事實上他們往往談論的不是一件事情。比如,之前就有人提問:Spark 1.x 就已經有程式碼生成技術,為什麼 Spark 2.0 又把程式碼生成吹了一番?其中的原因在於,雖然都是程式碼生成,但是各個產品生成程式碼的粒度是不同的:
- 最簡單的,例如 Spark 1.4,使用程式碼生成技術加速 表示式計算 ;
- Spark 2.0 支援將同一個 Stage 的 多個運算元組合編譯 成一段二進位制;
- 更有甚者,支援將 自定義函式、儲存過程 等編譯成一段二進位制,例如 SQL Server。
本文主要講上面最簡單的表示式編譯。讓我們通過一個簡單的例子,初步瞭解程式碼生成的流程。
解析執行的缺陷
在講程式碼生成之前,我們回顧一下解釋執行。以上面圖中的表示式 $X \times 5 + \log (10)$ 為例,計算過程是一個深度優先搜尋(DFS)的過程:
- 呼叫根節點
+
的visit()
函式:分別呼叫左、右子節點的visit()
再相加; - 呼叫乘法節點
*
的visit()
函式:分別呼叫左、右子節點的visit()
再相乘; - 呼叫變數節點
X
的visit()
函式:從環境中讀取 $X$ 的值以及型別。
(……略)最終,DFS 回到根節點,得到最終結果。
@Override public Object visitPlus(CalculatorParser.PlusContext ctx) { Object left = visit(ctx.plusOrMinus()); Object right = visit(ctx.multOrDiv()); if (left instanceof Long && right instanceof Long) { return (Long) left + (Long) right; } else if (left instanceof Long && right instanceof Double) { return (Long) left + (Double) right; } else if (left instanceof Double && right instanceof Long) { return (Double) left + (Long) right; } else if (left instanceof Double && right instanceof Double) { return (Double) left + (Double) right; } throw new IllegalArgumentException(); }
上述過程中有幾個顯而易見的效能問題:
- 涉及到大量的 虛擬函式呼叫 、即函式繫結的過程,例如
visit()
函式; - 在計算之前不能確定型別,因而各個運算元的實現中會出現很多 動態型別判斷 ,例如:如果
+
左邊是 DECIMAL 型別,而右邊是 DOUBLE,需要先把左邊轉換成 DOUBLE 再相加; - 遞迴中的 函式呼叫打斷了計算過程 ,不僅呼叫本身需要額外的指令,而且函式呼叫傳參是通過棧完成的,不能很好的利用暫存器(這一點在現代的編譯器和硬體體系中已經有所緩解,但顯然比不上連續的計算指令)。
程式碼生成基本過程
程式碼生成執行,顧名思義,最核心的部分是生成出我們需要的執行程式碼。
拜編譯器所賜,我們並不需要寫難懂的彙編或位元組碼。在 native 程式中,通常用 LLVM 的中間語言(IR)作為生成程式碼的語言。而 JVM 上更簡單,因為 Java 編譯本身很快,利用執行在 JVM 上的輕量級編譯器 janino,我們可以直接生成 Java 程式碼。
無論是 LLVM IR 還是 Java 都是靜態型別的語言,在生成的程式碼中再去判斷型別顯然不是個明智的選擇。 通常的做法是在編譯之前就確定所有值的型別 。幸運的是,表示式和 SQL 執行計劃都可以事先做型別推導。
所以,綜上所述,程式碼生成往往是個 2-pass 的過程: 先做型別推導,再做真正的程式碼生成 。第一步中,型別推導的同時其實也是在檢查表示式是否合法,因此很多地方也稱之為 驗證(Validate) 。
在程式碼生成完成後,呼叫編譯器,我們就編譯出了所需的函式(類),直接呼叫即可得到計算結果。如果函式包含引數,例如上面例子中的 X
,每次計算可以傳入不同的引數,編譯一次、計算多次。
以下的程式碼實現都可以在 GitHub 專案 ofollow,noindex">fuyufjh/calculator 找到。
驗證(Validate)
為了儘可能簡單,例子中僅涉及兩種型別:Long 和 Double
這一步中,我們將合法的表示式 AST 轉換成 Algebra Node,這是一個遞迴語法樹的過程,下面是一個例子(由於 Plus 接收 Long/Double 的任意型別組合,所以此處沒有做型別檢查):
@Override public AlgebraNode visitPlus(CalculatorParser.PlusContext ctx) { return new PlusNode(visit(ctx.plusOrMinus()), visit(ctx.multOrDiv())); }
AlgebraNode 介面定義如下:
public interface AlgebraNode { DataType getType(); // Validate 和 CodeGen 都會用到 String generateCode(); // CodeGen 使用 List<AlgebraNode> getInputs(); }
實現類大致與 AST 的中的節點相對應,如下圖。
對於加法,型別推導的過程很簡單——如果兩個運算元都是 Long 則結果為 Long,否則為 Double。
@Override public DataType getType() { if (dataType == null) { dataType = inferTypeFromInputs(); } return dataType; } private DataType inferTypeFromInputs() { for (AlgebraNode input : getInputs()) { if (input.getType() == DataType.DOUBLE) { return DataType.DOUBLE; } } return DataType.LONG; }
生成程式碼
依舊以加法為例,利用上面實現的 getType()
,我們可以確定輸入、輸出的型別,生成出強型別的程式碼:
@Override public String generateCode() { if (getLeft().getType() == DataType.DOUBLE && getRight().getType() == DataType.DOUBLE) { return "(" + getLeft().generateCode() + " + " + getRight().generateCode() + ")"; } else if (getLeft().getType() == DataType.DOUBLE && getRight().getType() == DataType.LONG) { return "(" + getLeft().generateCode() + " + (double)" + getRight().generateCode() + ")"; } else if (getLeft().getType() == DataType.LONG && getRight().getType() == DataType.DOUBLE) { return "((double)" + getLeft().generateCode() + " + " + getRight().generateCode() + ")"; } else if (getLeft().getType() == DataType.LONG && getRight().getType() == DataType.LONG) { return "(" + getLeft().generateCode() + " + " + getRight().generateCode() + ")"; } throw new IllegalStateException(); }
注意,目前程式碼還是以 String 形式存在的,遞迴呼叫的過程中通過字串拼接,一步步拼成完整的表示式函式。
以表示式 a + 2*3 - 2/x + log(x+1)
為例,最終生成的程式碼如下:
(((double)(a + (2 * 3)) - ((double)2 / x)) + java.lang.Math.log((x + (double)1)))
其中, a
、 x
都是未知數,但型別是已經確定的,分別是 Long 型和 Double 型。
編譯器編譯
Janino 是一個流行的輕量級 Java 編譯器,與常用的 javac
相比它最大的優勢是:可以在 JVM 上直接呼叫,直接在程序記憶體中執行編譯,速度很快。
上述程式碼僅僅是一個表示式、並不是完整的 Java 程式碼,但 janino 提供了方便的 API 能直接編譯表示式:
ExpressionEvaluator evaluator = new ExpressionEvaluator(); evaluator.setParameters(parameterNames, parameterTypes); // 輸入引數名及型別 evaluator.setExpressionType(rootNode.getType() == DataType.DOUBLE ? double.class : long.class); // 輸出型別 evaluator.cook(code); // 編譯程式碼
實際上,你也可以手工拼接出如下的類程式碼,交給 janino 編譯,效果是完全相同的:
class MyGeneratedClass { public double calculate(long a, double x) { return (((double)(a + (2 * 3)) - ((double)2 / x)) + java.lang.Math.log((x + (double)1))); } }
最後,依次輸入所有引數即可呼叫剛剛編譯的函式:
Object result = evaluator.evaluate(parameterValues);