Skip to content
14 changes: 14 additions & 0 deletions zml/io/vfs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ pub const VFS = struct {
}

fn lookupDir(self: *VFS, dir: std.Io.Dir, sub_path: ?[]const u8) !struct { ?usize, std.Io.Dir, std.Io } {
// A scheme-qualified path (e.g. "hf://owner/model/file") is absolute: it
// is resolved from the scheme's root regardless of `dir`. Without this,
// opening such a path relative to an already-open dir of the same
// backend double-prefixes the dir's path.
if (sub_path) |sp| {
if (std.mem.indexOf(u8, sp, "://") != null) {
const uri = std.Uri.parse(sp) catch return error.VFSNotRegistered;
const backend_idx: usize = for (self.backends.entries.items(.key), 0..) |s, idx| {
if (std.mem.eql(u8, uri.scheme, s)) break idx;
} else return error.VFSNotRegistered;
return .{ backend_idx, std.Io.Dir.cwd(), self.getBackend(backend_idx) };
}
}

if (std.meta.eql(dir, std.Io.Dir.cwd())) {
if (sub_path == null) return .{ null, dir, self.base.inner };
if (std.fs.path.isAbsolutePosix(sub_path.?)) return .{ null, dir, self.base.inner };
Expand Down
19 changes: 19 additions & 0 deletions zml/io/vfs/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
load("@rules_cc//cc:defs.bzl", "cc_import")
load("@rules_zig//zig:defs.bzl", "zig_library")

# xet-core C-API static lib, produced by `cargo build --release -p xet_capi` on
# the xet-core assaf/c-api branch (PR huggingface/xet-core#881). `xet_capi.zig`
# declares the C symbols `extern`, so only the static lib is needed for linking;
# no header include path has to be threaded through the build. Expose it as an
# external repo `@xet_capi` (prebuilt per platform, or via rules_rust).
# Extra link deps the Rust staticlib needs: Linux -lpthread -ldl -lm.
cc_import(
name = "xet_capi_import",
static_library = "@xet_capi//:libxet_capi.a",
visibility = ["//visibility:private"],
)

zig_library(
name = "vfs",
srcs = [
Expand All @@ -10,11 +23,17 @@ zig_library(
"http.zig",
"parallel_read.zig",
"s3.zig",
"xet.zig",
"xet_capi.zig",
"xet_hub.zig",
],
main = "index.zig",
tags = ["manual"],
visibility = ["//visibility:public"],
# hf.zig routes XET-backed files through xet.zig -> xet_capi.zig, which links
# libxet_capi.a.
deps = [
":xet_capi_import",
"//stdx",
],
)
75 changes: 75 additions & 0 deletions zml/io/vfs/XET_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Native XET download for ZML (via xet-core C-API)

Downloads HuggingFace XET-backed files by driving the mature **xet-core** (Rust)
client through its C-API, wired into the existing `hf://` VFS provider.

## Why the C-API

XET files reconstruct client-side (fetch compressed chunks from the CAS, LZ4/BG4
decompress, reassemble). Doing that fast needs adaptive concurrency, bounded
streaming, decompress-once scheduling, connection pooling and an on-disk chunk
cache; xet-core already has all of it. On an m6i.2xlarge, cold download of
`model-00001-of-00004.safetensors` (4.98 GB):

| path | MB/s |
|------|------|
| Zig -> C-API -> xet-core (raw), `HF_XET_HIGH_PERFORMANCE=1` | ~1070 |
| hf-xet (Python, same Rust core) | ~1030 |
| ZML `parallel_read.zig` (plain parallel HTTPS) | ~1000 |
| from-scratch Zig reimpl (jedisct1/zig-xet) | ~386 |

## How it is wired

- `xet_hub.zig`: HF Hub HTTPS/JSON (CAS read-token exchange, file listing).
- `xet_capi.zig`: `extern` declarations of the `hf_xet.h` C ABI (Session +
downloadToPath). Symbols are declared inline, so only linking `libxet_capi.a`
is needed, no header include path.
- `xet.zig`: `openRemote` -> `RemoteFile.readRange` (lazy range reads) and
`downloadFile` (eager whole-file).
- `hf.zig`: `performRead` detects XET-backed files on first access and, for
those, reconstructs each requested byte range on demand through the C-API
stream API (`readRange`), which fetches only the covering xorbs and serves
overlapping/repeated ranges from the chunk cache. Non-XET files fall back to
the existing `resolve` range-GET path. So `hf://` XET files go through
xet-core transparently, keeping ZML's lazy positional-read model.

Set `HF_XET_HIGH_PERFORMANCE=1` for peak concurrency.

## Verification (built + run in ZML's Bazel build)

Built `//examples/io:playground` with Zig 0.16.0 + `libxet_capi` from xet-core's
`assaf/c-api` branch (`cargo build --release -p xet_capi`), on an m6i.2xlarge.

- `xet_capi.zig` compiles and `libxet_capi.a` links into the real ZML binary,
and `hf.zig` reaches it.
- `playground load hf://.../model-00001-of-00004.safetensors` (a real model
weight load: parallel positional reads through `zml.io.TensorStore`) loaded
the 4.63 GiB shard's weights:

| load path | throughput |
|-----------|-----------|
| **lazy XET reads (this integration)** | **~348 MB/s** |
| `resolve` range-GET (baseline, `HF_XET_DISABLE=1`) | ~158 MB/s |

So XET is ~2.2x the current path for a real load, where reads are parallel and
the chunk cache helps. (`HF_XET_DISABLE=1` forces the resolve fallback.)

- On the degenerate `cp` pattern (serial 16 MB reads), lazy per-read
reconstruction instead loses to a bulk copy: ~36 MB/s lazy vs ~228 eager
(download-to-cache) vs ~23 resolve. Raw parallel xet download (no read loop)
is ~1070 MB/s. Lazy's win is parallel/partial reads, not serial full-file
copies.

## Known issues / follow-ups

- **Pre-existing crash on exit (not from this change):** `examples/io cp` aborts
in ZML's own `file://` provider (`file.zig` `getFileHandle` -> `fileClose`,
from `examples/io/main.zig:96`) after the file is fully written. The stack is
entirely in the local-file provider close path, unrelated to XET.
- **Cache location / lifecycle:** files are materialized under `/tmp/zml-xet-*`
and never evicted. Should live under xet-core's own cache dir and be reused.
- **Eager whole-file download** vs the lazy positional-read model; a design
decision for large shards.
- **Bazel `@xet_capi`** external repo must be declared (prebuilt per platform or
via rules_rust). The Rust static lib is large and cross-compiled per target,
which is the one real cost of this approach.
100 changes: 100 additions & 0 deletions zml/io/vfs/hf.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const stdx = @import("stdx");

const VFSBase = @import("base.zig").VFSBase;
const parallel_read = @import("parallel_read.zig");
const xet = @import("xet.zig");

const log = std.log.scoped(.@"zml/io/vfs/hf");

Expand Down Expand Up @@ -151,6 +152,10 @@ pub const HF = struct {
uri: []const u8,
pos: u64,
size: u64,
/// Set once we have decided whether this file is XET-backed.
xet_tried: bool = false,
/// Live xet-core session for range reads, if the file is XET-backed.
xet: ?xet.RemoteFile = null,

pub fn init(allocator: std.mem.Allocator, handle_type: Type, uri: []const u8, size: u64) !Handle {
return .{
Expand All @@ -163,6 +168,7 @@ pub const HF = struct {

pub fn deinit(self: *Handle, allocator: std.mem.Allocator) void {
allocator.free(self.uri);
if (self.xet) |*remote| remote.deinit(allocator);
}
};

Expand All @@ -176,6 +182,12 @@ pub const HF = struct {
base: VFSBase,
trees: std.StringHashMapUnmanaged(std.ArrayList(TreeNode)) = .{},
dir_read_states: std.AutoHashMapUnmanaged(*std.Io.Dir.Reader, ReadState) = .{},
/// Per-repo XET caches, keyed by "repo_id@rev". The repo tree and read token
/// are each fetched once and reused across all files, instead of once per
/// file (which rate-limits the Hub API into `too_many_requests`). Guarded by
/// `mutex`.
xet_trees: std.StringHashMapUnmanaged(xet.XetTree) = .{},
xet_tokens: std.StringHashMapUnmanaged(xet.ReadToken) = .{},

pub fn init(allocator: std.mem.Allocator, inner: std.Io, http_client: *std.http.Client, hf_token: ?[]const u8, opts: InitOpts) !HF {
const read_pool = try allocator.create(ParallelRead.Pool);
Expand Down Expand Up @@ -258,6 +270,20 @@ pub const HF = struct {
self.trees.deinit(self.allocator);
self.dir_read_states.deinit(self.allocator);

var xt = self.xet_trees.iterator();
while (xt.next()) |entry| {
xet.freeTree(self.allocator, entry.value_ptr);
self.allocator.free(entry.key_ptr.*);
}
self.xet_trees.deinit(self.allocator);

var tok = self.xet_tokens.iterator();
while (tok.next()) |entry| {
entry.value_ptr.deinit(self.allocator);
self.allocator.free(entry.key_ptr.*);
}
self.xet_tokens.deinit(self.allocator);

switch (self.authorization) {
.default, .omit => {},
.override => |t| self.allocator.free(t),
Expand Down Expand Up @@ -788,6 +814,25 @@ pub const HF = struct {
read_size = @intCast(@min(handle.size - offset, read_size));
if (read_size == 0) return 0;

// XET-backed files: reconstruct the requested range on demand through
// xet-core (C-API), which fetches only the covering xorbs and serves
// repeated/overlapping ranges from its chunk cache. Non-XET files fall
// through to the plain resolve range-GET path below.
if (!handle.xet_tried) {
handle.xet_tried = true;
handle.xet = self.openXet(handle) catch |err| blk: {
log.info("xet: falling back to resolve for {s}: {any}", .{ handle.uri, err });
break :blk null;
};
}
if (handle.xet) |*remote| {
const want = @min(data[0].len, read_size);
return remote.readRange(offset, offset + want, data[0][0..want]) catch |err| {
log.err("xet range read failed for {s} at {d}: {any}", .{ handle.uri, offset, err });
return error.XetReadFailed;
};
}

const job_count = std.math.divCeil(usize, read_size, self.read_pool.chunk_size) catch unreachable;

const jobs = try self.allocator.alloc(ParallelRead.Job, job_count);
Expand Down Expand Up @@ -823,4 +868,59 @@ pub const HF = struct {

return read_size;
}

/// Open a XET-backed file for range reads via xet-core. Returns an error
/// for non-XET files, so the caller falls back to the resolve range path.
fn openXet(self: *HF, handle: *Handle) !xet.RemoteFile {
const repo = try Repo.parse(handle.uri);

const repo_id = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ repo.repo, repo.model });
defer self.allocator.free(repo_id);

// Fetch the repo tree and read token once per repo (cached under mutex),
// not once per file, which otherwise rate-limits the Hub API into
// too_many_requests and forces every file onto the resolve fallback.
self.mutex.lockUncancelable(self.base.inner);
defer self.mutex.unlock(self.base.inner);

const tree = try self.getOrFetchXetTree(repo_id, repo.rev);
const file = tree.get(repo.path) orelse return error.FileNotXetBacked;
const token = try self.getOrFetchXetToken(repo_id, repo.rev);

// Session.init is local (no network) and copies the hash, so building it
// under the lock is cheap and keeps the cached token pointer valid.
return xet.openWith(self.allocator, file.hash_hexz, file.size, token.*);
}

/// The repo's XET tree, fetched once and cached. Caller must hold `mutex`.
fn getOrFetchXetTree(self: *HF, repo_id: []const u8, rev: []const u8) !*const xet.XetTree {
var key_buf: [1024]u8 = undefined;
const key = std.fmt.bufPrint(&key_buf, "{s}@{s}", .{ repo_id, rev }) catch return error.NameTooLong;
if (self.xet_trees.getPtr(key)) |cached| return cached;

var tree = try xet.fetchTree(self.allocator, self.client, self.authorization, "model", repo_id, rev);
errdefer xet.freeTree(self.allocator, &tree);

const owned_key = try self.allocator.dupe(u8, key);
errdefer self.allocator.free(owned_key);

try self.xet_trees.put(self.allocator, owned_key, tree);
return self.xet_trees.getPtr(owned_key).?;
}

/// The repo's XET read token, fetched once and cached. Caller must hold `mutex`.
fn getOrFetchXetToken(self: *HF, repo_id: []const u8, rev: []const u8) !*const xet.ReadToken {
var key_buf: [1024]u8 = undefined;
const key = std.fmt.bufPrint(&key_buf, "{s}@{s}", .{ repo_id, rev }) catch return error.NameTooLong;
if (self.xet_tokens.getPtr(key)) |cached| return cached;

const token = try xet.fetchToken(self.allocator, self.client, self.authorization, "model", repo_id, rev);
errdefer token.deinit(self.allocator);

const owned_key = try self.allocator.dupe(u8, key);
errdefer self.allocator.free(owned_key);

try self.xet_tokens.put(self.allocator, owned_key, token);
return self.xet_tokens.getPtr(owned_key).?;
}
};
1 change: 1 addition & 0 deletions zml/io/vfs/index.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pub const GCS = @import("gcs.zig").GCS;
pub const HF = @import("hf.zig").HF;
pub const HTTP = @import("http.zig").HTTP;
pub const S3 = @import("s3.zig").S3;
pub const xet = @import("xet.zig");
pub const VFSBase = @import("base.zig").VFSBase;
83 changes: 83 additions & 0 deletions zml/io/vfs/xet.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! Native XET reads for ZML, backed by xet-core (Rust) through its C-API.
//!
//! Rather than reimplementing the XET protocol in Zig, this drives the mature
//! xet-core client via `xet_capi.zig`. You get the same throughput as the
//! official `hf-xet` client (adaptive concurrency, streaming, decompress-once,
//! on-disk chunk cache).
//!
//! `openRemote` resolves a repo file to a reusable `RemoteFile` whose
//! `readRange` reconstructs arbitrary byte ranges on demand. This is what
//! `hf.zig` uses to serve positional reads of `hf://` XET files.
//!
//! Set `HF_XET_HIGH_PERFORMANCE=1` for peak concurrency/buffers.

const std = @import("std");

const hub = @import("xet_hub.zig");
const capi = @import("xet_capi.zig");

/// A repo file resolved to a live xet-core session, ready for range reads.
pub const RemoteFile = struct {
session: capi.Session,
hash: [:0]const u8,
size: u64,

pub fn deinit(self: *RemoteFile, allocator: std.mem.Allocator) void {
self.session.deinit();
allocator.free(self.hash);
}

/// Reconstruct `[start, end)` into `dest`, returning bytes written.
pub fn readRange(self: *RemoteFile, start: u64, end: u64, dest: []u8) !usize {
return self.session.readRange(self.hash, self.size, start, end, dest);
}
};

/// Repo-tree and read-token helpers, re-exported so callers (e.g. `hf.zig`) can
/// fetch each once per repo and cache them instead of per file.
pub const XetTree = hub.XetTree;
pub const XetFile = hub.XetFile;
pub const ReadToken = hub.ReadToken;
pub const Auth = hub.Auth;

pub fn fetchTree(
allocator: std.mem.Allocator,
client: *std.http.Client,
auth: hub.Auth,
repo_type: []const u8,
repo_id: []const u8,
revision: []const u8,
) !XetTree {
return hub.fetchXetTree(allocator, client, repo_type, repo_id, revision, auth);
}

pub fn freeTree(allocator: std.mem.Allocator, tree: *XetTree) void {
hub.freeXetTree(allocator, tree);
}

pub fn fetchToken(
allocator: std.mem.Allocator,
client: *std.http.Client,
auth: hub.Auth,
repo_type: []const u8,
repo_id: []const u8,
revision: []const u8,
) !ReadToken {
return hub.requestReadToken(allocator, client, repo_type, repo_id, revision, auth);
}

/// Open a XET-backed file for lazy range reads from an already-resolved
/// hash/size and a (cached) read token. `hash_hexz` is copied; the caller keeps
/// ownership of `token` (the C-API only borrows it during `Session.init`).
pub fn openWith(
allocator: std.mem.Allocator,
hash_hexz: [:0]const u8,
size: u64,
token: hub.ReadToken,
) !RemoteFile {
const hash = try allocator.dupeZ(u8, hash_hexz);
errdefer allocator.free(hash);

const session = try capi.Session.init(token.cas_url, token.access_token, token.exp);
return .{ .session = session, .hash = hash, .size = size };
}
Loading