三种把 SQLite 塞进 Nix 的方法
核心Nixpkgs-多元宇宙去除 Nix API 和 CLI 后,是一个索引。这是一张来自的地图(attribute, version)到以 JSON 文件形式发布的版本。
1
$ ls -lh index/
-rw-r--r--. 1 fmzakari fmzakari 7.5M Aug 19 13:57 history.json
-rw-r--r--. 1 fmzakari fmzakari 5.3M Aug 19 13:57 versions.json
截至9cc0209,versions.json为5.3 MiB,且history.json是7.5MiB,涵盖305,492个封装版本,涵盖31,904个封装和1,534个版本。
Nix API 加载 JSON 文件时是懒惰的,并且通过以下方式读取builtins.fromJSON:
index = builtins.fromJSON (builtins.readFile ./index/versions.json);
我希望用更多信息丰富数据,但这会带来代价:数据越多,问题越多。
该项目的目标是尽量减少下载的 Nixpkg 数量。如果我们仅仅用大 JSON 替换取巨型 Nixpkg,那就不是明显的胜利。
目前我们必须谨慎选择JSON文件中存储的内容,并思考巧妙的编码方案,使数据更小更紧凑。
如果我们不被限制在尼克斯builtins我们会利用成熟技术高效编码允许多重查询访问模式的数据集:数据库!
假设我们不被限制使用 JSON,还有其他选择吗?
一次查询会花费整个文件
为什么大型 JSON 文件会这么容易出问题?builtins.fromJSON是渴望.Nix里没有懒惰的JSON,也没有流式解析(比如“只给我这个密钥”)。
一旦你触碰结果,你就解析了全部5.3 MB,并在Nix堆上实现了全部305,492个值。
在多元宇宙中,要求一个包裹的费用和要求所有包裹的费用是一样的。
注释查找本身不是问题。Nix 属性集是已排序的数组,因此访问是二分搜索,而非扫描。成本完全取决于 JSON 解析、分配值和下载大文件。
如果我们想在索引上做交替问题,就必须确保答案高效存储,以更好地匹配访问模式。
我们想要的很明显。我们想要一种高效编码数据和声明式定义查询的方法:我们想要SQLite!2
$ sqlite3 index.db "SELECT version, rev FROM versions WHERE attr='hello'"
2.10|728
...
0.01s, 4 MB
什么都没有默认情况下做不到。很遗憾,没有builtins.sqlite,虽然我觉得应该有......
不过事实证明,我们确实可以调整一些旋钮或补丁来源,以获得想要的效果,尽管每个都有一个限制。😈
一:builtins.exec
我很惊讶自己之前不知道这件事builtin,并且一直存在于此1.11.9版本2017年4月。当现有资源无法完成各种应用时,它是终极的逃生出口。
builtins.exec取字符串列表,运行程序,且将其标准解析为Nix表达.
它被设置了门禁,明确表示不安全。
$ nix eval --option allow-unsafe-native-code-during-evaluation true \
--expr 'builtins.exec [ "/bin/sh" "-c" "echo 42" ]'
42
在集成方面,SQLite完全能够打印Nix语法。我们从不需要中间的序列化格式,因为我们让 SQLite 直接输出 attrset:
let
versionsOf = attr: builtins.exec [
"${sqlite}/bin/sqlite3" "-noheader" "-separator" "" "./index.db"
''
SELECT '{' || group_concat(
'"' || version || '" = ' ||
COALESCE(CAST(rev AS TEXT), 'null') || ';', ' ')
|| '}'
FROM versions WHERE attr = '${attr}';
''
];
in
versionsOf "hello"
$ nix eval --impure -f query.nix \
--option allow-unsafe-native-code-during-evaluation true
{
"2.10" = 728; "2.12" = 822; "2.12.1" = 1369;
"2.12.2" = 1486; "2.12.3" = null; "2.7" = 0; "2.8" = 13;
}
但前提是,现在每个查询都是fork, anexec,SQLite的进程镜像,以及通过Nix解析器重新解析输出。如果你不打算执行大量查询,考虑到集成的简单性,这种开销可能是可以接受的。
二:builtins.importNative
根据研究builtins.exec,我偶然发现了builtins.importNative.它会选择一条路径到一个共享对象和一个符号名称,dlopen并称之为该符号。
它落在1.8,2014年12月。3
共享对象必须实现以下签名:
extern "C" typedef void (*ValueInitializer)(EvalState & state, Value & v);
我们可以定义一个新的本地人返回输入版本的函数:
extern "C" void nix_sqlite_versions(EvalState & state, Value & v)
{
v.mkPrimOp(new PrimOp{
.name = "nix_sqlite_versions",
.args = {"dbPath", "attr"},
.arity = 2,
.impl = versions,
});
}
实现是普通的 C++,使用 Nix API。以下是实现过程的片段,确保缓存我们的sqlite3为了避免与启动惩罚相同的操作builtins.exec:
/* The whole point: the database handle outlives a single query, so the
b-tree pages we touch stay warm for the rest of the evaluation. */
std::map handles;
void versions(EvalState & state, const PosIdx pos,
Value ** args, Value & v)
{
std::string path(state.forceStringNoCtx(*args[0], pos, "..."));
std::string attr(state.forceStringNoCtx(*args[1], pos, "..."));
// cached across calls
auto * db = openOnce(state, pos, path);
sqlite3_stmt * stmt = nullptr;
sqlite3_prepare_v2(db,
"SELECT version, rev "
"FROM versions "
"WHERE attr = ?1",
-1, &stmt, nullptr);
sqlite3_bind_text(stmt, 1, attr.data(),
attr.size(), SQLITE_TRANSIENT);
/* ... collect rows ... */
/* Build the attrset directly. No text ever exists. */
auto bindings = state.buildBindings(rows.size());
for (auto & [version, rev] : rows) {
auto & slot = bindings.alloc(state.symbols.create(version));
if (rev) slot.mkInt(*rev); else slot.mkNull();
}
v.mkAttrs(bindings);
}
使用方式如下:
$ nix eval --impure \
--option allow-unsafe-native-code-during-evaluation true \
--expr '(builtins.importNative
./libnixsqlite.so "nix_sqlite_versions"
) "./index.db" "hello"'
{
"2.10" = 728; "2.12" = 822; "2.12.1" = 1369;
"2.12.2" = 1486; "2.12.3" = null; "2.7" = 0; "2.8" = 13;
}
三:builtins.wasm
确定系统我发了第三个选项2026年3月:builtins.wasm调用WebAssembly模块内的一个函数。4动机类似于想扩大Nix的表面积但避免扩张builtins.Wasm是沙盒式且确定性的,因此与上述两个内置软件不同,目标是提供一个安全逃生舱口.
WebAssembly 是一种用于基于堆栈虚拟机的二进制指令格式。有人声称它非常适合尼克斯,因为它确实如此确定性执行这比后门要克制得多builtins.exec.
编写模块
模块需要导出memory,一个名为nix_wasm_init_v1,以及入口。
#![no_std]
#![no_main]
type ValueId = u32;
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// Host functions supplied by the Nix evaluator.
#[link(wasm_import_module = "env")]
unsafe extern "C" {
fn get_int(v: ValueId) -> i64;
fn make_int(n: i64) -> ValueId;
}
#[unsafe(no_mangle)]
pub extern "C" fn nix_wasm_init_v1() {}
fn fib(n: i64) -> i64 {
if n <= 1 { 1 } else { fib(n - 1) + fib(n - 2) }
}
#[unsafe(no_mangle)]
pub extern "C" fn fib_entry(arg: ValueId) -> ValueId {
unsafe { make_int(fib(get_int(arg))) }
}
Nixpkgs 已经包含了交叉编译的目标,所以制作一个相当简单:
pkgs.runCommand "nix-wasm-rust-fib"
{
nativeBuildInputs = [ pkgs.rustc pkgs.lld ];
src = ./modules.rs;
} ''
mkdir -p $out
rustc --target wasm32-unknown-unknown --crate-type cdylib -O \
-o $out/modules.wasm $src
''
$ nix eval --extra-experimental-features wasm-builtin \
--expr 'builtins.wasm { path = ./modules.wasm;
function = "fib_entry"; } 30'
1346269
你通过 Nix API 函数调用求值器,所以 wasm 模块构建真实的 Nix 值,类似于builtins.importNative只是没有脚枪。
我可以HAZSQLite吗?
SQLite 发布了官方的 WASM 构建版本所以这些碎片似乎就在那儿,我脑子里的齿轮开始转动。

尝试用传统 Nix 加载 SQLite 数据库的初步尝试builtins有点失败,因为 Nix 字符串不能包含 NULL 字节。
$ nix eval --impure --expr 'builtins.stringLength (builtins.readFile ./index.db)'
error: the contents of the file '/tmp/mvsql/index.db' cannot be represented as a Nix string
幸运的是,在大型语言模型(LLM)的额外尽职调查帮助下,我们发现博客文章中没有包含Nix API函数之一:
/**
* Read the contents of a file into Wasm memory. This is like calling
* `builtins.readFile`, except that it can handle binary files that
* cannot be represented as Nix strings.
*/
uint32_t read_file(ValueId pathId, uint32_t ptr, uint32_t len)
read_file是具体来说专门为这个问题设计。该功能允许WASM模块从磁盘中任意拉取原始字节到内存中。
不幸的是,这有点太宽泛了其中内容如下完整档案这有点过头了,也是我们想避免的初始JSON解决方案。
在追求探索的过程中,让我们补丁实现并增强了 API,允许对文件进行随机访问和部分读取。结果发现要加的补丁相对小且简单。
/**
* Read a range of a file into Wasm memory, starting at `offset`
* and copying at most `len` bytes.
* Returns the number of bytes actually copied.
*/
uint32_t read_file_range(ValueId pathId, uint64_t offset,
uint32_t ptr, uint32_t len)
{
auto & pathValue = getValue(pathId);
auto path = state.realisePath(noPos, pathValue);
auto buf = memory().subspan(ptr, len);
/* If this is a real file on disk, do a positional read*/
if (auto physical = path.getPhysicalPath()) {
AutoCloseFD fd{open(physical->string().c_str(),
O_RDONLY | O_CLOEXEC)};
if (!fd)
throw SysError("opening file '%s'", physical->string());
auto n = pread(fd.get(), buf.data(), len, offset);
if (n < 0)
throw SysError("reading file '%s'", physical->string());
return n;
}
/* Otherwise fall back to materialising the whole file. */
auto contents = path.readFile();
if (offset >= contents.size())
return 0;
auto n = std::min(len, contents.size() - offset);
memcpy(buf.data(), contents.data() + offset, n);
return n;
}
现在我们拥有连接SQLite所需的一切,并有一个自定义的虚拟文件系统(VFS)层可以读取提供的/nix/store路径入口。
我们构建了SQLite的WASM目标,并设置SQLITE_OS_OTHER=1.该标志移除了SQLite的整个VFS层,并要求我们提供一个。
pkgs.pkgsCross.wasi32.stdenv.mkDerivation {
pname = "sqlite-nix-wasm";
buildPhase = ''
$CC -O2 -o sqlite_nix.wasm \
-I${amalgamation} ${amalgamation}/sqlite3.c sqlite_nix.c \
-DSQLITE_OS_OTHER=1 \
-DSQLITE_THREADSAFE=0 \
-DSQLITE_OMIT_LOAD_EXTENSION \
-DSQLITE_OMIT_WAL \
-Wl,--export-memory
'';
}
我们为构建提供了简单的实现xReadAPI是通过新暴露的调用Nix评估器nix_read_file_range功能。其他的都是存根。
static const sqlite3_io_methods nixIoMethods = {
.iVersion = 1,
.xClose = nixClose,
.xRead = nixRead,
.xFileSize = nixFileSize,
.xDeviceCharacteristics = nixDeviceCharacteristics,
/* ... the rest are stubs ... */
};
static int nixRead(sqlite3_file *f, void *buf,
int amt, sqlite3_int64 off)
{
NixFile *p = (NixFile *) f;
/* The one line that matters: SQLite's pager asks
for a page, and we ask the Nix evaluator for
exactly those bytes. */
unsigned got = nix_read_file_range(p->pathId, (unsigned long long) off,
buf, (unsigned) amt);
if (got < (unsigned) amt) {
memset((char *) buf + got, 0, (unsigned) amt - got);
return SQLITE_IOERR_SHORT_READ;
}
return SQLITE_OK;
}
注释不幸的是builtins.wasm使每个调用 为新实例.这是实现时的刻意安排,意味着我们每次都要支付一些启动代码,虽然没有像fork&exec
使用方式如下:5
$ nix eval --extra-experimental-features wasm-builtin \
--expr 'builtins.wasm { path = ./sqlite_nix.wasm; }
{ db = ./index.db; attr = "hello"; }'
{
"2.10" = 728; "2.12" = 822; "2.12.1" = 1369;
"2.12.2" = 1486; "2.12.3" = null; "2.7" = 0; "2.8" = 13;
}
那是真正的完整的SQLite包含所有功能:准备好的语句、绑定参数、通过索引的B树下降,以及在Nix评估器内执行。全部通过WebAssembly。
🤯
基准测试
这四种方法有什么不同?以下是四种方法,分别回答同一个问题:“该包是哪些版本发布的?”对比同一22MB的SQLite构建索引。
import pandas as pd
from plotnine import *
# Best of five runs each. Random attributes drawn from the real index.
# `nix eval --expr '1+1'` costs 0.03s, the floor every line sits on.
# The first three run on stock Nix 2.34.7; the wasm line needs the patched
# Determinate Nix, whose baseline is the same 0.03s.
rows = [
("builtins.fromJSON", [0.29, 0.30, 0.27, 0.29]),
("builtins.exec", [0.05, 0.08, 0.22, 0.74]),
("builtins.importNative", [0.05, 0.04, 0.05, 0.05]),
("SQLite in wasm", [2.79, 2.46, 3.10, 4.37]),
]
queries = [1, 10, 50, 200]
df = pd.DataFrame({
"queries": queries * len(rows),
"seconds": [v for _, vs in rows for v in vs],
"how": [k for k, vs in rows for _ in vs],
})
plot = (
ggplot(df, aes("queries", "seconds", color="how"))
+ geom_line(size=1.0)
+ geom_point(size=1.8)
+ scale_x_log10(breaks=queries, labels=[str(q) for q in queries])
+ scale_y_log10()
+ scale_color_manual(values={"builtins.fromJSON": "#8a8580",
"builtins.exec": "#4c72b0",
"builtins.importNative": "#b1201d",
"SQLite in wasm": "#d1892f"},
name="")
+ labs(x="point queries in one evaluation", y="seconds (log scale)")
+ theme(legend_position="top", legend_title=element_blank())
)
plot.width, plot.height = 7.0, 3.8
正如我们最初抱怨的,fromJSON是一条平线出现在错误的位置。无论你问一个问题还是两百个问题,速度都是0.29秒,因为5.3MB的解析只发生一次,之后就占了主导地位。
builtins.exec起步最便宜,然后逐步攀升,每次查询大约3.8毫秒fork+exec+ Nix解析输出。它穿越了fromJSON大约有八十个查询。
builtins.importNative平坦且几乎自由,在整个范围内 0.05秒,因为我们重用 SQLite 快照跨越多次召唤。整个评估只打开一次数据库,页面保持温暖。
不幸的是,WASM中的SQLite主要受固定成本影响,大致2.5秒在第一个问题之前,然后是关于7毫秒此后每一次查询。这2.5秒是Cranelift编译1.1 MB的SQLite。
目前这是WASM实现的一个局限,不过Eelco提到,生成的代码未来可以在调用之间缓存到磁盘上。
对于一个锁定文件,钉住三十个包裹,fromJSON以当前指数规模,他依然能直接获胜。
我真正想要的是什么
这三者都不适合多元宇宙索引的配对,我也不会做nixpkgs-multiverse取决于allow-unsafe-native-code-during-evaluation.让别人开启本地代码加载来让我的假开发更快,这请求并不值得目前.
目前索引仍是 JSON 格式,我暂时保留了一些更高层次的想法,这些想法需要数据量大增.
虽然从哲学上讲,我只用CppNix我对生态系统通过WASM能解锁的东西感到有些好奇和印象深刻。不过确实存在一些不足,比如等待JIT处理,以及开发者体验上可能有编译过的blob,但确实有潜力解锁各种问题。
- 实际上还有一些其他文件驱动其他功能,比如统计数据。“快速模式”但它们也是 JSON 格式。↩
- Nixpkgs-多元宇宙已经导出了SQLite数据库作为包,帮助他人探索这些数据。↩
- C++字段最初被称为
enableImportNative并更名为enableNativeCode对于exec.↩ - Eelco曾就此做过一次演讲。23倍比例.↩
- 别忘了,这需要我们打过补丁的版本Determiante 系统的 Nix.↩