57 萬行 LLM 程式碼編譯成功,速度卻比 SQLite 慢了 20,171 倍
有人對 LLM 用 Rust 重新實作的 SQLite 做了效能測試。看起來正確和真正正確之間的差距,竟然達到五個數量級。
最近有人對一個完全由 LLM 撰寫的 SQLite Rust 重實作版本進行了效能測試。程式碼可以編譯,測試也通過了,寫法乾淨、結構清晰、符合 idiomatic Rust 的風格。但在最基本的 primary key lookup 上,它比 SQLite 慢了 20,171 倍。
這個數字讓我停下來想了很久。不是因為 LLM 產生的程式碼跑得慢有什麼好驚訝的,而是慢的原因。這份程式碼沒有任何地方是 compiler 或測試套件能夠抓到的錯誤。B-tree 的實作是正確的,query planner 存在,storage engine 也可以運作。每個單獨的零件都站得住腳,整個系統卻幾乎無法使用。
我花了一些時間仔細看 benchmark 分析報告和原始碼。裡頭浮現的模式在 LLM 產生的專案中不斷重複出現,我認為這指向了這些模型在寫程式時的某種根本性問題。
B-tree 在那裡,但 query planner 完全無視它
在 SQLite 裡,一次 PRIMARY KEY lookup 會走 B-tree 路徑,以 O(log n) 的時間完成。where.c 裡的四行程式碼負責檢查 iPKey 並直接導向樹狀結構。這是那種只有在理解整個系統如何組合在一起之後,才會覺得有意義的微最佳化。
LLM 產生的版本也有 B-tree 實作,單獨跑起來完全正確。問題在於 query planner 在處理 primary key lookup 時根本沒有呼叫它。is_rowid_ref() 函式只辨識三個字面字串:"rowid"、"_rowid_" 和 "oid"。如果你把某個欄位宣告成 id INTEGER PRIMARY KEY,planner 不會把它視為 rowid 的別名,每次查詢都會走 full table scan。
這個數學算起來相當慘烈。100 筆資料查詢 100 次,B-tree 路徑大約需要 700 次比較步驟,full scan 路徑則超過 10,000 次。但真正的傷害來自演算法複雜度的差異:每次 lookup 從 O(log n) 變成 O(n),在整個 benchmark 套件中累積下來,就構成了 20,171 倍的差距。
這種 bug 沒有任何 unit test 能夠抓到,除非你特別去寫 benchmark。B-tree 可以運作,scan 也可以運作,planner 只是選錯了路徑,所有測試都通過。
安全的預設值會像複利一樣疊加
這個案例比單純的 routing bug 更有意思的地方在這裡。就算把 query planner 的問題修掉,這個重實作版本還是慢了大約 2,900 倍。剩餘的差距來自一堆各自看起來都很合理的決策。
每次執行查詢都會把整個 AST clone 一份並重新編譯成 bytecode。SQLite 則是重用已準備好的 statement handle。兩種做法都成立,但每次執行都 clone 一次 AST,規模一大就非常昂貴。
每次讀取 page 都重新在 heap 上配置一塊 4KB 的 buffer。SQLite 的 page cache 則是直接回傳指向已載入記憶體的 pointer。LLM 版本選了最安全、最直觀的路:配置、讀取、回傳。可以運作,只是在每次查詢要讀取幾千個 page 時慢了好幾個數量級。
每次 commit 都從頭重建整個 schema。SQLite 只比較一個整數 cookie 值,如果 cookie 沒變,schema 就還是有效的。這個重實作版本沒有這個概念,所以每次都把完整的工作做一遍。
每條 statement 都觸發 sync_all() 把所有檔案 metadata 寫入磁碟。SQLite 使用 fdatasync(),只 flush 檔案資料,跳過 metadata sync。這個差異在 write-heavy 的工作負載下影響極大。
我想把這稱為「防禦性預設值的複合效應」。每一個選擇單獨看都有合理的理由。Clone AST 是為了避免 Rust 的 ownership 複雜性,配置新 buffer 是為了防止 use-after-free,重建 schema 是為了避免 stale cache 問題,呼叫 sync_all() 提供最強的 durability 保證。
但效能的成本是相乘的,不是相加的。四個各自 10 倍的懲罰疊加起來,不會讓你慢 40 倍,而是慢 10,000 倍。LLM 不會去推理這種複合效應,因為它是相對獨立地產生每個函式。它在局部做最佳化,卻在整體付出代價。
82,000 行程式碼取代一行 cron 指令
同一位開發者的另一個 LLM 產生的專案,以不同的方式呈現了一樣的模式。問題很簡單:Rust 的 target/ 目錄裡的 build artifact 會慢慢吃掉磁碟空間。LLM 的解法:一個 82,000 行的 Rust daemon,帶有七個 dashboard 和一個 Bayesian 評分引擎,用來決定要清除哪些 artifact。
現有的解法是 find ./target -type f -atime +30 -delete,一行 cron job。零依賴。或者用 cargo-sweep,一個社群官方工具,早就存在了,而且還處理了那個 daemon 沒有覆蓋到的邊界情況。
這個 LLM 產生的專案引入了 192 個 dependency。作為對照,ripgrep 是 Rust 生態系裡最精密的搜尋工具之一,只用了 61 個。
這是我一直看到的模式:LLM 幫你打造你要求的東西,而不是你真正需要的東西。如果你 prompt「打造一個能夠智慧管理 Rust build artifact 並提供監控和評分的系統」,你就會拿到完全符合描述的東西。這個模型沒有機制退一步問:這個問題根本需要一個系統嗎?它不知道 target/ 目錄大小是 Rust 社群的老問題,早就有已知解法。它不會考慮 192 個 dependency 的維護成本和零個之間的差距。
研究結果指向同一個方向
我很好奇這兩個專案是不是特例,所以查了更廣泛的研究。它們不是。
METR 對 16 位有經驗的開源開發者進行了隨機對照試驗。使用 AI 工具的那組完成任務的速度比對照組慢了 19%。讓我印象深刻的是:實驗結束後,使用 AI 的那組認為自己快了 20%。主觀的生產力感受和實際測量結果完全相反。
GitClear 分析了 2.1 億行程式碼,發現複製貼上的程式碼首度超越了重構的程式碼。這個趨勢直接和 AI coding 工具的採用率相關。程式碼被加入的速度,快過它被改善的速度。
Google 的 DORA 2024 報告發現,AI 採用率增加 25%,與 deployment 穩定性下降 7.2% 相關。進入 production 的 AI 產生程式碼越多,發生的 incident 也越多。
NeurIPS 2024 的 Mercury benchmark 在標準 coding benchmark 上加入了效率指標。當你衡量的不只是「輸出是否正確」,而是「輸出是否在不浪費資源的情況下正確」,通過率就掉到了 50% 以下。
這些都不代表 LLM 對寫程式沒有用。我自己一直在用。但這確實意味著「可以編譯且測試通過」是一個危險地低的標準。看起來正確的程式碼和真正正確的程式碼之間的落差,才是真正的工程師在做的事情。
這對開發者真正的要求
核心問題不是 LLM 寫出了差勁的程式碼,而是它們寫出了局部一致、整體卻不一致的程式碼。每個函式單獨來看都說得通,整個系統卻不對。這正是傳統測試會漏掉的失敗模式,因為測試只驗證局部行為。
需要的是針對這些落差設計的評估方式。Benchmark,而不只是 unit test。在 CI 裡設定效能預算,而不只是正確性檢查。在確認某個 module 是否能運作之前,先做架構審查,問「這個 module 為什麼存在」。進行 dependency audit,把解法的複雜度和問題本身的複雜度做比較。
問題不是「這段程式碼看起來對嗎?」而是「我們要如何證明它是對的?」要證明這件事,需要 LLM 目前還缺乏的系統層級思考。
你要求的東西和 production 的要求之間的落差,就是工程判斷力存在的地方。沒有量測,程式碼生成只不過是 token 生成。
訂閱電子報
獲取關於我最新專案、文章以及 AI 和 Web 開發實驗的更新。