目錄
2 分鐘閱讀

LLM 寫了 57 萬行 Rust 程式,編譯成功,比 SQLite 慢 20,171 倍

有人對 LLM 用 Rust 重寫的 SQLite 進行基準測試。結果揭示了「看起來正確的程式碼」與「真正正確的程式碼」之間,可以相差五個數量級。

有人用 LLM 全程生成了一個 Rust 版本的 SQLite 重寫項目,並對它進行基準測試。程式碼能編譯。測試全部通過。程式碼乾淨、結構清晰、符合慣用 Rust 風格。但在最基本的 primary key 查詢上,它比 SQLite 慢了 20,171 倍。

這個數字讓我停下來想了很久。不是因為 LLM 生成的程式碼跑得慢令人意外,而是因為慢的根源所在。這些程式碼並沒有任何編譯器或測試套件能捕捉到的錯誤。B-tree 實作是正確的。Query planner 存在。Storage engine 能運作。每個部分單獨看都站得住腳。但整個系統合在一起,幾乎無法使用。

我花了時間細讀基準測試分析和原始碼。當中發現的模式在 LLM 生成的項目中反覆出現,我認為這些模式指向了這類模型寫程式碼的某個根本性問題。

B-tree 存在,但 query planner 完全無視它

在 SQLite 裡,PRIMARY KEY 查詢會走 B-tree 路徑,以 O(log n) 的時間完成。where.c 裡四行程式碼負責檢查 iPKey 並將查詢直接導向 B-tree。這類微優化只有在你理解整個系統如何協同運作時,才能做到。

LLM 生成的版本也有 B-tree 實作,而且在獨立測試時運作正確。問題在於 query planner 在處理 primary key 查詢時,從來不調用它。is_rowid_ref() 函式只識別三個字面字符串:“rowid”、“rowid” 和 “oid”。如果你宣告了一個 id INTEGER PRIMARY KEY 的欄位,planner 並不會將它識別為 rowid 的別名。每一個查詢都走了全表掃描。

這裡的數字很殘酷。對 100 行資料查詢 100 次,B-tree 路徑大約需要 700 步比較。全表掃描路徑則超過 10,000 步。但真正的傷害來自算法複雜度的差異:每次查詢從 O(log n) 變成 O(n),在整個基準測試套件中累積下來,就造成了 20,171 倍的差距。

這類 bug 沒有任何 unit test 能捕捉到,除非你專門寫基準測試。B-tree 能用。全表掃描也能用。Planner 只是選錯了路徑。一切都通過測試。

安全預設值像複利一樣疊加

這個案例比單純的路由 bug 更有意思的地方,正在這裡。即使修正了 query planner 的問題,這個重寫版本仍然大約慢了 2,900 倍。剩餘的差距來自一系列各自看似合理的決策堆疊在一起。

每次執行查詢都會 clone 完整的 AST,並重新編譯成 bytecode。SQLite 重用 prepared statement handle。兩種做法都有效,但每次執行都 clone AST,在規模化時代價高昂。

每次讀取頁面都在 heap 上分配一個全新的 4KB buffer。SQLite 的 page cache 直接回傳指向已載入記憶體的指標。LLM 版本選擇了安全、顯而易見的做法:分配、讀取、回傳。它能運作,只是當每個查詢需要讀取數千個頁面時,速度慢了好幾個數量級。

每次 commit 都從頭重建整個 schema。SQLite 只比較一個整數 cookie 值。如果 cookie 沒有變,schema 就仍然有效。重寫版本沒有這個概念,所以每次都做完整的重建工作。

每個 statement 都觸發 sync_all() 呼叫,將所有檔案 metadata 刷新到磁碟。SQLite 使用 fdatasync(),只刷新檔案資料,跳過 metadata sync。在寫入密集的工作負載下,這個差異影響極大。

我把這稱為「防禦性預設值的複利效應」。每個決策單獨看都有合理的理由。Clone AST 可以避免 Rust 的 ownership 複雜性。分配新的 buffer 防止 use-after-free。重建 schema 避免 stale cache 問題。呼叫 sync_all() 提供最強的持久性保障。

但效能成本是相乘的,不是相加的。四個各自 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 個依賴。作為參考,ripgrep 這個 Rust 生態中最精密的搜尋工具之一,只用了 61 個。

這是我不斷看到的一個模式:LLM 建構你要求的東西,而不是你需要的東西。如果你的 prompt 是「建立一個能智能管理 Rust build artifact 並附帶監控和評分功能的系統」,你得到的就是這個。模型沒有機制退一步問,這個問題究竟是否需要一個「系統」。它不知道 target/ 目錄佔用空間是 Rust 社群裡有口皆碑的問題,而且早有廣為人知的解法。它不會考慮 192 個依賴與零依賴之間的維護成本差異。

研究數據指向同一個方向

我好奇這兩個項目是否只是個例,於是查閱了更廣泛的研究。它們並不是。

METR 做了一項隨機對照實驗,對象是 16 位有經驗的開源開發者。使用 AI 工具的那組完成任務的速度,比對照組慢了 19%。讓我印象深刻的是:實驗結束後,AI 組的人認為自己快了 20%。生產力的主觀感受,與實際測量結果完全相反。

GitClear 分析了 2.1 億行程式碼,發現複製貼上的程式碼首次超越了重構的程式碼。這個趨勢與 AI 輔助編程工具的普及直接相關。程式碼增加的速度,快過了它被改善的速度。

Google 的 DORA 2024 報告發現,AI 採用率提升 25%,與部署穩定性下降 7.2% 相關。越多 AI 生成的程式碼進入生產環境,越多事故就隨之而來。

NeurIPS 2024 的 Mercury 基準測試在標準編程基準之上加入了效率指標。當評量標準不再只是「能否輸出正確結果」,而是「能否不浪費資源地輸出正確結果」時,通過率跌破 50%。

這一切並不代表 LLM 對編程沒有用。我自己也在持續使用。但這確實意味著「能編譯且測試通過」是一個危險的低標準。「看起來合理的程式碼」與「真正正確的程式碼」之間的差距,正是真實工程工作發生的地方。

這對開發者的真正要求是什麼

核心問題不在於 LLM 寫出爛程式碼。它們寫出的程式碼在局部是連貫的,但在全局是不連貫的。每個函式都有道理,但整個系統沒有。這恰好是傳統測試漏掉的那種失效模式,因為測試驗證的是局部行為。

需要的是針對這些空隙的評估方式。不只是測試,還要有基準測試。CI 裡不只是正確性檢查,還要有效能預算。架構審查要先問「這個模組為何存在」,再去確認它是否運作。依賴審計要把解決方案的複雜度與問題本身的複雜度作比較。

問題不是「這段程式碼看起來對嗎」,而是「我們如何證明它是對的」。而要證明,需要的正是 LLM 目前缺乏的系統層面思考能力。

你要求的東西與生產環境的實際要求之間的落差,就是工程判斷力存在的地方。沒有測量,程式碼生成不過是 token 生成。

訂閱通訊

獲取關於我最新項目、文章同埋 AI 和 Web 開發實驗嘅更新。