alitrack

duckdb-luajit:Lua 里调 Go,比 Lua 原生慢 4 倍

前几天翻到两篇讲「Lua 调 Go」的文章,顺手在自己维护的 DuckDB LuaJIT 扩展里试了一把。

结论先放这:链路是通的,冷启动两三毫秒,热调用几微秒;但做简单的字符串替换,Go 比 Lua 原生慢 4 倍。

● ● ●

我在试什么

我维护一个叫 duckdb-luajit 的 DuckDB 扩展,把 Lua 编译进数据库里当 UDF 用。扩展本身很轻,1MB。LuaJIT 自带 FFI,意思是 Lua 代码里可以 ffi.load 任意动态库——扩展市场里的线性代数库(LAPACK)、优化求解器(HiGHS)都是这么挂进去的。

那 Go 呢?

Go 能编译成 C 共享库(-buildmode=c-shared),而 FFI 能加载 C 库。理论上这条路通。但理论和实测之间,往往隔着一个坑。

参考材料有两篇:

  • Calling Go functions from LUA,2017 年,作者踩了用 FFI 声明 Go 结构体的坑

https://titpetric.com/2017/03/13/calling-go-functions-from-lua/

  • Mixing Go and Lua,2024 年,speedata Publisher 作者的生产实践

https://news.speedata.de/2024/02/21/mixinggoandlua/

第二篇的建议很实用:接口签名直接用 C.char,别用 Go string。因为 Go string 在 C 眼里是个 {ptr, len} 结构体,跨语言传一趟要转换两次;用 C.char 就省了。

● ● ●

PoC:三步走

第一步,写 Go 库。 只导出两个函数:一个调 regexp.ReplaceAllString,一个排序拼接。

package main

import "C"
import (
    "regexp"
    "sort"
    "strings"
)

//export sdReplace
func sdReplace(text *C.char, re *C.char, replace *C.char) *C.char {
    reC, err := regexp.Compile(C.GoString(re))
    if err != nil {
        return C.CString("ERR_REGEX: " + err.Error())
    }
    return C.CString(reC.ReplaceAllString(C.GoString(text), C.GoString(replace)))
}

//export sdSortJoin
func sdSortJoin(items *C.char, sep *C.char) *C.char {
    parts := strings.Split(C.GoString(items), ",")
    sort.Strings(parts)
    return C.CString(strings.Join(parts, C.GoString(sep)))
}

func main() {}

编译一行:go build -buildmode=c-shared -o replacer.so main.go。

第二步,Lua 侧声明并加载。

local ffi = require('ffi')
ffi.cdef[[
  extern char *sdReplace(const char* text, const char* re, const char* replace);
  extern char *sdSortJoin(const char* items, const char* sep);
  extern void free(void* ptr);
]]
local lib = ffi.load('/path/to/replacer.so')

第三步,在 DuckDB 里当 UDF 调。

SELECT luajit_s('gopoc', {op: 'replace', text: 'abc123def456', re: '\d+', repl: 'N'});
-- → abcNdefN

全通。整条链路长这样:

调用链:SQL → LuaJIT → FFI → Go 共享库

调用链:SQL → LuaJIT → FFI → Go 共享库

● ● ●

踩到的坑:ffi.C.free 要先声明

Go 返回的是 C.CString,这块内存得在 Lua 侧 ffi.C.free(ret) 释放。我第一版没声明 free,直接报错:

missing declaration for symbol 'free'

注意,报的不是「找不到符号」。LuaJIT FFI 要求 ffi.C 命名空间里任何 C 函数都先在 cdef 里声明过,free 也不例外。补一行 extern void free(void* ptr); 就好了。

● ● ●

实测数据

环境:go1.25.8,DuckDB 1.5.5,Linux x86-64。计时用 Lua 的 os.clock(),每个数字跑 3 轮取区间。

项
数据
ffi.load
 + Go runtime 初始化 + 首个调用
1.9–3.1ms
热调用(第二次起)
4–8µs/次
10000 次 \d+ 替换:Go regexp
14.3–15.9ms
10000 次 \d+ 替换:Lua 原生 gsub
3.6–3.8ms

冷启动那点是意外收获。Go 共享库自带整个 runtime,我原本预计首次加载要几百毫秒。实际 Go 的 c-shared 是懒初始化,几毫秒里大部分还是 FFI 绑定的开销。

然后是微基准。10000 次 \d+ 替换,Lua 原生 gsub 比 Go regexp 快约 4 倍。

原因不复杂:gsub 是 Lua 解释器里的 C 实现,零跨语言开销;我这次的 Go 版每次调用都重新 regexp.Compile 一遍,还得过 FFI 边界。

● ● ●

把这次实测和两篇参考文章的结论对齐,判据挺清晰:

不值得:简单替换、匹配、字符串处理。Lua 原生 gsub/match 更快,还省一个 .so。

值得:Lua pattern 的能力缺口。Lua 正则没有 lookahead、lookbehind、后向引用,这些在日志解析、复杂文本抽取里是刚需;再就是 Go 标准库独有的能力,比如 XML、Unicode 处理、网络客户端。

判据:什么时候值得挂 Go 库

判据:什么时候值得挂 Go 库

还有一点工程上的:真要走这条路,把 regexp.Compile 提到库加载时做一次,别每次调用都编译。这次 PoC 为了代码短是每次编译的,基准数字偏保守。

● ● ●

复现

PoC 是 5 个文件:main.go、gopoc.lua、test_go_poc.sql、bench_go_poc.sql、run.sh。扩展是 Linux ELF,得在 WSL 或 Linux 下跑。

bash run.sh

run.sh 干三件事:go build 出 replacer.so、nm -D 查符号、duckdb -unsigned 跑冒烟测试和冷启动计时。整套不到 1 秒。


延伸:speedata 那篇还讲了更激进的做法——Go 函数直接实现 Lua C API 签名(func f(L C.lua_State) C.int),省掉中间层,在 Go 里直接操作 Lua 栈建表。性能上限更高,但代码开始难看了。等有真实需求再说。*

链路能通,不代表该用。这条 Go 路我只会用在 Lua 真缺能力的地方。

需要这套 PoC 文件的,留言或私信我。你要是在 DuckDB 里挂过别语言的库,或者碰到过 Lua pattern 搞不定的正则,也欢迎留言说说。

● ● ●

参考来源

  1. 01
    Calling Go functions from LUA

https://titpetric.com/2017/03/13/calling-go-functions-from-lua/

  1. 01
    Mixing Go and Lua — speedata Publisher

https://news.speedata.de/2024/02/21/mixinggoandlua/

  1. 01
    duckdb-luajit(本文用的 DuckDB LuaJIT 扩展)

https://github.com/alitrack/duckdb-luajit

  1. 01
    duckdb-luajit-libs(扩展市场,linalg/optimize 等库就在这)

https://github.com/alitrack/duckdb-luajit-libs