Pkg + BinaryBuilder -- 下一代

2019 年 11 月 19 日 | Elliot Saba, Stefan Karpinski, Kristoffer Carlsson

在过去几个月里,我们一直在迭代和改进 Julia 1.3+ 中 Pkg 的设计,以处理非 Julia 包的二进制对象。虽然这项工作的动机是为了改善使用 BinaryBuilder.jl 构建的二进制文件的安装体验,但其工件子系统更加通用,可广泛应用于所有 Julia 包。

Pkg 工件

工件,如 Pkg.jl#1234 中所述,现在在 Pkg.jl 的最新文档中 有所记录,提供了一种将数据容器与 Julia 项目和包关联的便捷方式。工件通过内容哈希引用,或者可选地通过名称引用,该名称通过 Artifacts.toml 文件绑定到哈希。以下是一个示例 Artifacts.toml 文件

[socrates]
git-tree-sha1 = "43563e7631a7eafae1f9f8d9d332e3de44ad7239"
lazy = true

    [[socrates.download]]
    url = "https://github.com/staticfloat/small_bin/raw/master/socrates.tar.gz"
    sha256 = "e65d2f13f2085f2c279830e863292312a72930fee5ba3c792b14c33ce5c5cc58"

    [[socrates.download]]
    url = "https://github.com/staticfloat/small_bin/raw/master/socrates.tar.bz2"
    sha256 = "13fc17b97be41763b02cbb80e9d048302cec3bd3d446c2ed6e8210bddcd3ac76"

[[c_simple]]
arch = "x86_64"
git-tree-sha1 = "4bdf4556050cb55b67b211d4e78009aaec378cbc"
libc = "musl"
os = "linux"

    [[c_simple.download]]
    sha256 = "411d6befd49942826ea1e59041bddf7dbb72fb871bb03165bf4e164b13ab5130"
    url = "https://github.com/JuliaBinaryWrappers/c_simple_jll.jl/releases/download/c_simple+v1.2.3+0/c_simple.v1.2.3.x86_64-linux-musl.tar.gz"

[[c_simple]]
arch = "x86_64"
git-tree-sha1 = "51264dbc770cd38aeb15f93536c29dc38c727e4c"
os = "macos"

    [[c_simple.download]]
    sha256 = "6c17d9e1dc95ba86ec7462637824afe7a25b8509cc51453f0eb86eda03ed4dc3"
    url = "https://github.com/JuliaBinaryWrappers/c_simple_jll.jl/releases/download/c_simple+v1.2.3+0/c_simple.v1.2.3.x86_64-apple-darwin14.tar.gz"

[processed_output]
git-tree-sha1 = "1c223e66f1a8e0fae1f9fcb9d3f2e3ce48a82200"

Artifacts.toml 绑定了三个工件;一个名为 socrates,一个名为 c_simple,一个名为 processed_output。工件的唯一必要信息是它的 git-tree-sha1。由于工件仅通过其内容哈希寻址,因此 Artifacts.toml 文件的目的是提供有关这些工件的元数据,例如将人类可读的名称绑定到内容哈希,提供有关工件从何处下载的信息,甚至将单个名称绑定到多个哈希,这些哈希按平台特定约束(如操作系统或 libgfortran 版本)进行键控。

工件类型和属性

在上面的示例中,socrates 工件展示了一个具有多个下载位置的平台无关工件。下载和安装 socrates 工件时,将按顺序尝试 URL,直到一个成功。socrates 工件被标记为 lazy,这意味着它不会在安装包含包时在 Pkg.add() 时自动下载,而是在包首次尝试使用它时按需下载。BinaryBuilder.jl 本身就是一个延迟工件的实际示例,它使用 大量延迟工件 为多种语言和平台系统目标提供编译器,按需下载名为 RustToolchain-aarch64-linux-musl.v1.18.3.x86_64-linux-gnu.squashfs 等的工件。

c_simple 工件展示了一个平台相关工件,其中 c_simple 数组中的每个条目都包含帮助调用包根据主机特性的具体情况选择适当下载的键。请注意,每个工件都包含 git-tree-sha1 和每个下载条目的 sha256。这样做是为了确保下载的 tarball 在尝试解压缩之前是安全的,并强制所有 tarball 必须扩展到相同的整体树哈希。

processed_output 工件不包含 download 部分,因此无法安装。这样的工件将是之前运行的代码的结果,生成一个新的工件并将结果哈希绑定到此项目中的名称。这对能够轻松引用工件以及确保它最终不会被 Pkg 垃圾回收器收集起来非常有用。

使用工件

可以使用从 Pkg.Artifacts 命名空间中公开的便捷 API 来操作工件。作为一个动机示例,假设我们正在编写一个需要加载 Iris 机器学习数据集 的包。虽然我们可以在构建步骤中将数据集下载到包目录中,并且许多包目前正是这样做,但这有一些明显的缺点。首先,它会修改包目录,使包安装变为有状态的,我们希望避免这种情况。在未来,我们希望达到包可以完全只读安装,而不是在安装后能够修改自身。其次,下载的数据不会在包的不同版本之间共享。如果我们为不同项目安装了包的三个不同版本,那么我们需要数据的三份不同副本,即使这些位在不同版本之间是相同的。此外,每次我们升级或降级包时,除非我们做一些巧妙(并且可能是脆弱的)事情,否则我们将不得不再次下载数据。使用工件,我们将检查我们的 iris 工件是否已存在于磁盘上,只有在不存在的情况下才会下载并安装它,然后我们将结果绑定到我们的 Artifacts.toml 文件中

using Pkg.Artifacts

# This is the path to the Artifacts.toml we will manipulate
artifacts_toml = joinpath(@__DIR__, "Artifacts.toml")

# Query the `Artifacts.toml` file for the hash bound to the name "iris"
# (returns `nothing` if no such binding exists)
iris_hash = artifact_hash("iris", artifacts_toml)

# If the name was not bound, or the hash it was bound to does not exist, create it!
if iris_hash == nothing || !artifact_exists(iris_hash)
    # create_artifact() returns the content-hash of the artifact directory once we're finished creating it
    iris_hash = create_artifact() do artifact_dir
        # We create the artifact by simply downloading a few files into the new artifact directory
        iris_url_base = "https://archive.ics.uci.edu/ml/machine-learning-databases/iris"
        download("$(iris_url_base)/iris.data", joinpath(artifact_dir, "iris.csv"))
        download("$(iris_url_base)/bezdekIris.data", joinpath(artifact_dir, "bezdekIris.csv"))
        download("$(iris_url_base)/iris.names", joinpath(artifact_dir, "iris.names"))
    end

    # Now bind that hash within our `Artifacts.toml`.  `force = true` means that if it already exists,
    # just overwrite with the new content-hash.  Unless the source files change, we do not expect
    # the content hash to change, so this should not cause unnecessary version control churn.
    bind_artifact!(artifacts_toml, "iris", iris_hash)
end

# Get the path of the iris dataset, either newly created or previously generated.
# this should be something like `~/.julia/artifacts/dbd04e28be047a54fbe9bf67e934be5b5e0d357a`
iris_dataset_path = artifact_path(iris_hash)

对于使用之前绑定的工件的特定用例,我们有简写符号 artifact"name",它会自动搜索当前包中包含的 Artifacts.toml 文件,按名称查找给定的工件,如果它尚未安装则安装它,然后返回给定工件的路径。

工件生命周期

所有工件都安装在 Julia 包库中的全局 artifacts 目录中,通常在 ~/.julia/artifacts 中,并按内容哈希进行键控。虽然工件旨在与 Julia 的多库分层系统很好地配合使用,并且还提供了 覆盖工件位置的机制 以帮助希望使用特定本地版本的库的系统管理员,但总的来说,我们发现将二进制工件安装到这样的单个用户拥有的位置对普通 Julia 用户非常有效。这些工件会一直保留在磁盘上,直到 Pkg.gc() 清理至少一个月未使用的包和工件。当特定版本/内容哈希未被磁盘上的任何 Manifest.tomlArtifacts.toml 文件引用时,垃圾回收器会确定工件和包未使用。垃圾回收器会遍历 Julia 加载过的所有 Manifest.tomlArtifacts.toml 文件,并将所有可访问的工件和包版本标记为已使用。然后,将所有未标记的工件和包版本标记为未使用,任何连续标记为未使用一个月或更长时间的工件和包版本都会被自动删除。

此时间延迟可以通过将 collect_delay 关键字参数设置为更小的值来配置,例如 Pkg.gc(;collect_delay=Hour(1))(务必导入 Dates 标准库以获取 Hour 等时间函数!),以删除所有未使用一个小时或更长时间的工件。这种宽限期应该消除大多数用户因更改包版本或在切换项目后重新安装包而需要重新下载相同的大型二进制包而产生的挫败感。

BinaryBuilder.jl

你可能已经猜到 BinaryBuilder.jl 知道如何生成 Artifacts.toml 文件 (示例),但这并不是唯一的变化。我们厌倦了当前的最佳实践,即需要手动在包的 deps/build.jl 文件中表达所有依赖项的图。迄今为止,还没有一种简单明了的方法可以递归地安装二进制依赖项;用户被迫采用诸如将所有二进制依赖项的 build.jl 文件嵌入到他们自己的包中的策略。这样做可以,但比我们想要的笨拙得多。幸运的是,我们已经有了一个知道如何处理递归依赖项的包管理器,因此一个简单的解决方案出现了:作为 BinaryBuilder.jl 运行的输出的一部分,我们将生成一个包装的 Julia 包,它将同时允许我们表达二进制依赖项的 DAG,并提供样板 Julia 包装代码,这将使处理库和可执行文件变得更加简单。我们将这些自动生成的包称为 JLL 包。

Julia 库 (JLL) 包

自动生成的 BinaryBulider 生成的包是正常的 Julia 包,其中显著包含 Artifacts.toml 文件,这些文件会下载由 BinaryBuilder 构建并上传到存储库的 GitHub 版本的适当版本的任何二进制工件。我们将这些自动生成的包简称为“Julia 库包”或 JLL。BinaryBuilder 尝试将所有包上传到 JuliaBinaryWrappers/$(package_name)_jll.jl,但这当然是可配置的。这里给出了一个示例包 示例,最有趣的部分是这些自动生成的包公开的新 API。

JLL 包中的代码绑定是根据生成包的 build_tarballs.jl 文件中定义的 Products 自动生成的。出于示例目的,我们将假设定义了以下产品

products = [
    FileProduct("src/data.txt", :data_txt),
    LibraryProduct("libdataproc", :libdataproc),
    ExecutableProduct("mungify", :mungify_exe)
]

有了这样的产品定义,JLL 包将包含导出的 data_txtlibdataprocmungify_exe 符号。对于 FileProduct 变量,导出的值是指向磁盘上文件位置的字符串。对于 LibraryProduct 变量,它是一个对应于所需库的 SONAME 的字符串(它将在 JLL 包模块的 __init__() 方法中自动 dlopen(),因此典型的 ccall() 用法适用),对于 ExecutableProduct 变量,导出的值是一个可以调用的函数,用于设置适当的环境变量,例如 PATHLD_LIBRARY_PATH。这是必要的,以便嵌套依赖项正常工作,例如 ffmpeg 在视频编码期间调用 x264 二进制文件。示例

using c_simple_jll

# For file products, you can access its file location directly:
data_lines = open(data_txt, "r") do io
    readlines(io)
end

# For library products, you can use the exported variable name in `ccall()` invocations directly
num_chars = ccall((libdataproc, :count_characters), Cint, (Cstring, Cint), data_lines[1], length(data_lines[1]))

# For executable products, you can use the exported variable name as a function that you can call
mungify_exe() do mungify_exe_path
    run(`$mungify_exe_path $num_chars`)
end

这个新系统的一大好处是,运行必须链接到另一个依赖项提供的库或调用另一个依赖项中提供的二进制文件的二进制文件,所有这些都完美无缺地工作,因为包装的 JLL 包会自动设置适当的环境变量。

build_tarballs.jl 的更改

对于我们中间的那些二进制构建老手,构建二进制文件的过程并没有太大变化。第一个变化是对笨拙的产品 API 的一些清理;以前你需要提供一个闭包来将 prefix 绑定到你的产品中;现在不再需要这样做。为了明确这一点,虽然你以前会编写如下声明

products(prefix) = [
    LibraryProduct(prefix, "libglib", :libglib)
]

现在你只需要写

products = [
    LibraryProduct("libglib", :libglib)
]

第二个变化是依赖项不再直接链接到 build.jl 文件,而是提供你想要安装的 JLL 包的名称(或名称和版本),它们(以及所有递归依赖项)将被安装并符号链接到 ${prefix} 中,与以前类似。为了明确这一点,虽然你以前会编写如下声明

dependencies = [
    "https://github.com/JuliaBinaryWrappers/c_simple_jll.jl/releases/download/c_simple%2Bv1.2.3%2B7/build_c_simple.v1.2.3.jl",
]

现在你只需要写

dependencies = [
    "c_simple_jll",
]

如果你想指定二进制依赖项的特定版本,你可以提供完整的 PackageSpec,如下所示

dependencies = [
    Pkg.Types.PackageSpec(;name="c_simple_jll", version=v"1.2.3"),
]

由于 JLL 包与任何其他公共包一样进行注册,因此安装这些构建依赖项就像查询注册表、克隆 JLL 包的最新版本、检查其 Artifacts.toml 文件并在构建前缀中解压缩相应的工件一样简单。更棒的是,这些构建依赖项会自动记录为生成的新的 JLL 包的依赖项。

Yggdrasil,一个构建配方集合

以前,鼓励每个用户创建自己的“构建者仓库”,Travis CI 在其中构建其依赖项的二进制文件。这使得设置比我们期望的工具更加复杂和困难,并且还导致了发现问题,即很难确定是否有人已经为特定依赖项构建了配方。为了解决这两个问题,我们现在在 JuliaPackaging/Yggdrasil 中有一个社区构建树。BinaryBuilder.jl 向导的用户通常会向 Yggdrasil 发起拉取请求,并且在其阴暗的分支中已经可以找到许多构建配方。

更新您的包

要更新您的包,首先使用最新版本的 BinaryBuilder 进行构建,然后在您的项目和包中添加自动生成的 JLL 包作为依赖项,使用新的 API 进行您的 ccall()run() 二进制文件,并在您删除 deps/build.jl 文件时露出微笑。死亡到所有全局可变状态。

超越 JLL 包

例如,要了解 JLL 包如何不仅仅用于 JLL 包,请参阅 Gtk.jl 中的示例

mutable_artifacts_toml = joinpath(dirname(@__DIR__), "MutableArtifacts.toml")
loaders_cache_name = "gdk-pixbuf-loaders-cache"
loaders_cache_hash = artifact_hash(loaders_cache_name, mutable_artifacts_toml)
if loaders_cache_hash === nothing
    # Run gdk-pixbuf-query-loaders, capture output,
    loader_cache_contents = gdk_pixbuf_query_loaders() do gpql
        withenv("GDK_PIXBUF_MODULEDIR" => gdk_pixbuf_loaders_dir) do
            return String(read(`$gpql`))
        end
    end

    # Write cache out to file in new artifact
    loaders_cache_hash = create_artifact() do art_dir
        open(joinpath(art_dir, "loaders.cache"), "w") do io
            write(io, loader_cache_contents)
        end
    end
    bind_artifact!(mutable_artifacts_toml,
        loaders_cache_name,
        loaders_cache_hash;
        force=true
    )
end

# Point gdk to our cached loaders
ENV["GDK_PIXBUF_MODULE_FILE"] = joinpath(artifact_path(loaders_cache_hash), "loaders.cache")
ENV["GDK_PIXBUF_MODULEDIR"] = gdk_pixbuf_loaders_dir

Gtk__init__() 方法中,它正在检查是否生成了 gdk-pixbuf 加载程序的本地缓存。此缓存是特定于机器的,必须在模块首次运行时生成。这样做需要调用 gdk_pixbuf_jll 包中的二进制文件,这是使用该 JLL 包的 gdk_pixbuf_query_loaders 导出项完成的。通过将结果写入新的工件并将该工件绑定到 MutableArtifacts.toml 文件(一个任意命名的 .gitignore'd 文件),我们能够动态缓存与其他包数据分开保存的二进制对象。此后,Gtk 通过环境变量被告知缓存位置。我们希望通过在未来的 Pkg 版本中引入 显式生命周期的缓存 来进一步改善这种体验。

可重复性很重要

总之,我们希望这些新功能能够让您能够编写更加可靠的 Julia 包。该系统具有在优秀的 Julia 包系统中工作的巨大优势,获得了 Manifests 的所有可重复性优势以及包解析器的兼容性检查功能。这意味着现在当您六个月后回到一个项目时,实例化它不仅会安装您之前使用的确切 Julia 源代码,还会获取在它工作时安装的确切库版本。这是我们在努力真正控制我们的应用程序和系统构建在其之上的整个计算平台方面取得的巨大进步,我们期待看到您作为社区在这个激动人心的功能之上构建的惊人项目。