三种把 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 构建版本所以这些碎片似乎就在那儿,我脑子里的齿轮开始转动。

photo of a cat asking if he can have sqlite as a meme

尝试用传统 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,但确实有潜力解锁各种问题。

  1. 实际上还有一些其他文件驱动其他功能,比如统计数据。“快速模式”但它们也是 JSON 格式。↩
  2. Nixpkgs-多元宇宙已经导出了SQLite数据库作为包,帮助他人探索这些数据。↩
  3. C++字段最初被称为enableImportNative并更名为enableNativeCode对于exec.↩
  4. Eelco曾就此做过一次演讲。23倍比例.↩
  5. 别忘了,这需要我们打过补丁的版本Determiante 系统的 Nix.↩
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论