Skip to main content

rlx_pkg/
write.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Write flat / directory / ZIP `.rlxp` packages.
6
7use crate::flat::{FlatPayload, FlatTensorMeta, write_flat};
8use crate::manifest::{DistRef, Manifest, SidecarRef};
9use crate::placement::Placement;
10use crate::store_zip;
11use crate::tier::{DEFAULT_WARM_BLOCK, StorageTier, checksum_hex, encode_for_tier};
12use crate::weights_index::{WeightEntry, WeightsIndex};
13use anyhow::{Context, Result, bail};
14use rlx_ir::{Graph, Op};
15use serde::Serialize;
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19/// On-disk container kind.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum ContainerKind {
22    /// Single-file mmap blob (default for `.rlxp`) — fastest load, least overhead.
23    #[default]
24    Flat,
25    /// ZIP64 STORE — inspectable with `unzip`; slower/larger than flat.
26    Zip,
27    /// Unpacked directory tree — best for editing during development.
28    Dir,
29}
30
31/// Options for [`write_package`].
32///
33/// Defaults favor deploy packs: strip graph weight bytes, cold-compress
34/// sidecars, embed the MIR graph, xxh3 checksums, and TOC string interning.
35#[derive(Debug, Clone)]
36pub struct WriteOptions {
37    /// Package display name (manifest `name`).
38    pub name: String,
39    /// Optional producer string (e.g. `"rlx-bake"`).
40    pub producer: Option<String>,
41    /// Capability / feature tags written into the manifest.
42    pub features: Vec<String>,
43    /// Flat / ZIP / directory container.
44    pub container: ContainerKind,
45    /// Sidecar blobs: `(id, media_type, bytes)`.
46    pub sidecars: Vec<(String, String, Vec<u8>)>,
47    /// Optional distribution placement metadata.
48    pub placement: Option<Placement>,
49    /// When true (default), clear weight `Constant` payloads in the embedded
50    /// graph so bytes live only in the data region (no duplex copy on disk).
51    pub strip_graph_weights: bool,
52    /// Compress sidecars with cold zstd (default true).
53    pub compress_sidecars: bool,
54    /// Uncompressed block size for warm `zstd_blocks` (default 1 MiB).
55    pub warm_block_size: u32,
56    /// Embed MIR graph bytes (default true). Set false for weight-only packs.
57    pub include_graph: bool,
58    /// Write xxh3 checksums of uncompressed payloads into the TOC.
59    pub write_checksums: bool,
60    /// Prefer bincode TOC envelope (flat only; smaller/faster than JSON).
61    pub bincode_toc: bool,
62    /// Intern repeated name strings into TOC `strings` table (flat).
63    pub intern_strings: bool,
64}
65
66impl Default for WriteOptions {
67    fn default() -> Self {
68        Self {
69            name: "model".into(),
70            producer: None,
71            features: Vec::new(),
72            container: ContainerKind::Flat,
73            sidecars: Vec::new(),
74            placement: None,
75            strip_graph_weights: true,
76            compress_sidecars: true,
77            warm_block_size: DEFAULT_WARM_BLOCK,
78            include_graph: true,
79            write_checksums: true,
80            bincode_toc: false,
81            intern_strings: true,
82        }
83    }
84}
85
86/// One weight to pack (from bake or another producer).
87///
88/// `scheme` is a string such as `"f32"` or `"gguf_q4_k"`. `tier` selects
89/// hot mmap vs warm/cold compression ([`StorageTier`]).
90#[derive(Debug, Clone)]
91pub struct PackedWeight {
92    pub name: String,
93    pub shape: Vec<usize>,
94    pub scheme: String,
95    pub layout: String,
96    pub data: Vec<u8>,
97    pub rank: Option<u32>,
98    /// Hot (mmap), warm (blocked zstd), or cold (whole zstd).
99    pub tier: StorageTier,
100}
101
102impl PackedWeight {
103    /// Convenience constructor with [`StorageTier::Hot`].
104    pub fn hot(
105        name: impl Into<String>,
106        shape: Vec<usize>,
107        scheme: impl Into<String>,
108        layout: impl Into<String>,
109        data: Vec<u8>,
110    ) -> Self {
111        Self {
112            name: name.into(),
113            shape,
114            scheme: scheme.into(),
115            layout: layout.into(),
116            data,
117            rank: None,
118            tier: StorageTier::Hot,
119        }
120    }
121}
122
123/// Write a package to `out` (path inferred as flat / ZIP / dir unless
124/// [`WriteOptions::container`] is set).
125pub fn write_package(
126    out: impl AsRef<Path>,
127    graph: &Graph,
128    weights: &[PackedWeight],
129    opts: &WriteOptions,
130) -> Result<()> {
131    let out = out.as_ref();
132    match opts.container {
133        ContainerKind::Flat => write_package_flat(out, graph, weights, opts),
134        ContainerKind::Zip => write_package_zip(out, graph, weights, opts),
135        ContainerKind::Dir => write_package_dir(out, graph, weights, opts),
136    }
137}
138
139/// Strip named weight constant payloads from a graph clone (disk size win).
140pub fn graph_with_stripped_weights(graph: &Graph, weight_names: &[&str]) -> Graph {
141    let mut g = graph.clone();
142    for n in g.nodes_mut() {
143        let Some(name) = n.name.as_deref() else {
144            continue;
145        };
146        if !weight_names.contains(&name) {
147            continue;
148        }
149        if let Op::Constant { data } = &mut n.op {
150            data.clear();
151        }
152    }
153    g
154}
155
156/// Write the flat mmap package (default `.rlxp`).
157pub fn write_package_flat(
158    path: impl AsRef<Path>,
159    graph: &Graph,
160    weights: &[PackedWeight],
161    opts: &WriteOptions,
162) -> Result<()> {
163    let names: Vec<&str> = weights.iter().map(|w| w.name.as_str()).collect();
164    let graph_for_disk = if opts.strip_graph_weights {
165        graph_with_stripped_weights(graph, &names)
166    } else {
167        graph.clone()
168    };
169
170    let (mut manifest, _index, _shard, _io, _extra) =
171        build_manifest_only(&graph_for_disk, weights, opts)?;
172    // Flat packs do not use path-based members; clear path pointers that zip/dir use.
173    manifest.weights = None;
174    manifest.graph.path = "__flat__/graph".into();
175    manifest.sidecars.clear();
176    for (id, media, _) in &opts.sidecars {
177        manifest.sidecars.push(SidecarRef {
178            id: id.clone(),
179            path: format!("__flat__/sidecar/{id}"),
180            media_type: Some(media.clone()),
181            codec: crate::tier::Codec::None, // flat uses FlatSidecar.codec
182        });
183    }
184    if opts.placement.is_some() {
185        manifest.dist = Some(DistRef {
186            placement: Some("__flat__/placement".into()),
187        });
188    }
189
190    let placement_bytes = opts
191        .placement
192        .as_ref()
193        .map(serde_json::to_vec)
194        .transpose()
195        .context("serialize placement")?;
196
197    let graph_bytes = if opts.include_graph {
198        bincode::serialize(&graph_for_disk).context("serialize graph")?
199    } else {
200        Vec::new()
201    };
202
203    let payload = FlatPayload {
204        graph: graph_bytes,
205        weights: weights
206            .iter()
207            .map(|w| {
208                (
209                    FlatTensorMeta {
210                        name: w.name.clone(),
211                        shape: w.shape.clone(),
212                        scheme: w.scheme.clone(),
213                        layout: w.layout.clone(),
214                        rank: w.rank,
215                        tier: w.tier,
216                        checksum: if opts.write_checksums {
217                            Some(checksum_hex(&w.data))
218                        } else {
219                            None
220                        },
221                    },
222                    w.data.clone(),
223                )
224            })
225            .collect(),
226        sidecars: opts
227            .sidecars
228            .iter()
229            .map(|(id, media, data)| (id.clone(), Some(media.clone()), data.clone()))
230            .collect(),
231        placement: placement_bytes,
232        warm_block_size: opts.warm_block_size,
233        compress_sidecars: opts.compress_sidecars,
234        placement_hot: true,
235        bincode_toc: opts.bincode_toc,
236        intern_strings: opts.intern_strings,
237        include_graph: opts.include_graph,
238    };
239    write_flat(path, manifest, payload)
240}
241
242/// Write an unpacked directory package.
243pub fn write_package_dir(
244    dir: impl AsRef<Path>,
245    graph: &Graph,
246    weights: &[PackedWeight],
247    opts: &WriteOptions,
248) -> Result<()> {
249    let dir = dir.as_ref();
250    std::fs::create_dir_all(dir.join("graph"))
251        .with_context(|| format!("mkdir {}", dir.display()))?;
252    std::fs::create_dir_all(dir.join("weights/shards"))?;
253    if !opts.sidecars.is_empty() {
254        std::fs::create_dir_all(dir.join("sidecars"))?;
255    }
256    if opts.placement.is_some() {
257        std::fs::create_dir_all(dir.join("dist"))?;
258    }
259
260    let names: Vec<&str> = weights.iter().map(|w| w.name.as_str()).collect();
261    let graph_for_disk = if opts.strip_graph_weights {
262        graph_with_stripped_weights(graph, &names)
263    } else {
264        graph.clone()
265    };
266
267    let (manifest, index, shard_bytes, io_json, members_extra) =
268        build_members(&graph_for_disk, weights, opts)?;
269
270    write_json(dir.join("rlx.json"), &manifest)?;
271    if opts.include_graph {
272        write_bytes(
273            dir.join("graph/mir.bin"),
274            &bincode::serialize(&graph_for_disk)?,
275        )?;
276    }
277    write_json(dir.join("graph/io.json"), &io_json)?;
278    if let Some(idx) = &index {
279        write_json(dir.join("weights/index.json"), idx)?;
280        write_bytes(dir.join("weights/shards/000.pack.bin"), &shard_bytes)?;
281    }
282    for (rel, data) in &members_extra {
283        let p = dir.join(rel);
284        if let Some(parent) = p.parent() {
285            std::fs::create_dir_all(parent)?;
286        }
287        write_bytes(p, data)?;
288    }
289    Ok(())
290}
291
292/// Write a ZIP64 STORE archive (inspectable; larger/slower than flat).
293pub fn write_package_zip(
294    path: impl AsRef<Path>,
295    graph: &Graph,
296    weights: &[PackedWeight],
297    opts: &WriteOptions,
298) -> Result<()> {
299    let path = path.as_ref();
300    if let Some(parent) = path.parent() {
301        if !parent.as_os_str().is_empty() {
302            std::fs::create_dir_all(parent)?;
303        }
304    }
305    let names: Vec<&str> = weights.iter().map(|w| w.name.as_str()).collect();
306    let graph_for_disk = if opts.strip_graph_weights {
307        graph_with_stripped_weights(graph, &names)
308    } else {
309        graph.clone()
310    };
311    let (manifest, index, shard_bytes, io_json, members_extra) =
312        build_members(&graph_for_disk, weights, opts)?;
313
314    let mut members: Vec<(String, Vec<u8>)> = Vec::new();
315    members.push((
316        "rlx.json".into(),
317        serde_json::to_vec_pretty(&manifest).context("serialize rlx.json")?,
318    ));
319    if opts.include_graph {
320        members.push((
321            "graph/mir.bin".into(),
322            bincode::serialize(&graph_for_disk).context("serialize graph")?,
323        ));
324    }
325    members.push((
326        "graph/io.json".into(),
327        serde_json::to_vec_pretty(&io_json).context("serialize io.json")?,
328    ));
329    if let Some(idx) = &index {
330        members.push((
331            "weights/index.json".into(),
332            serde_json::to_vec_pretty(idx).context("serialize weights index")?,
333        ));
334        members.push(("weights/shards/000.pack.bin".into(), shard_bytes));
335    }
336    members.extend(members_extra);
337
338    store_zip::write_store_zip(path, &members)?;
339    Ok(())
340}
341
342#[derive(Serialize)]
343struct IoJson {
344    inputs: Vec<IoEntry>,
345    outputs: Vec<IoEntry>,
346}
347
348#[derive(Serialize)]
349struct IoEntry {
350    name: String,
351    shape: Vec<usize>,
352    dtype: String,
353}
354
355fn build_manifest_only(
356    graph: &Graph,
357    weights: &[PackedWeight],
358    opts: &WriteOptions,
359) -> Result<(
360    Manifest,
361    Option<WeightsIndex>,
362    Vec<u8>,
363    IoJson,
364    Vec<(String, Vec<u8>)>,
365)> {
366    build_members(graph, weights, opts)
367}
368
369fn build_members(
370    graph: &Graph,
371    weights: &[PackedWeight],
372    opts: &WriteOptions,
373) -> Result<(
374    Manifest,
375    Option<WeightsIndex>,
376    Vec<u8>,
377    IoJson,
378    Vec<(String, Vec<u8>)>,
379)> {
380    let mut manifest = Manifest::new_v1(opts.name.clone());
381    manifest.producer = opts.producer.clone();
382    manifest.features = opts.features.clone();
383    manifest.created_unix = Some(
384        SystemTime::now()
385            .duration_since(UNIX_EPOCH)
386            .map(|d| d.as_secs())
387            .unwrap_or(0),
388    );
389
390    let mut features = manifest.features.clone();
391    if weights.iter().any(|w| w.scheme.starts_with("gguf_")) {
392        push_feat(&mut features, "dequant_matmul");
393    }
394    if weights
395        .iter()
396        .any(|w| w.scheme.starts_with("mlx_affine") || w.scheme.starts_with("mlx_mxfp"))
397    {
398        push_feat(&mut features, "dequant_matmul");
399        push_feat(&mut features, "mlx_packed");
400    }
401    if opts.placement.is_some() {
402        push_feat(&mut features, "dist_placement");
403    }
404    for (id, _, _) in &opts.sidecars {
405        push_feat(&mut features, &format!("sidecar_{id}"));
406    }
407    push_feat(&mut features, "strip_graph_weights");
408    if !opts.include_graph {
409        push_feat(&mut features, "weight_only");
410        manifest.graph.encoding = "none".into();
411    }
412    if weights.iter().any(|w| w.tier != StorageTier::Hot) {
413        push_feat(&mut features, "hybrid_storage");
414        if weights.iter().any(|w| w.tier == StorageTier::Warm) {
415            push_feat(&mut features, "zstd_blocks_warm");
416        }
417        if weights.iter().any(|w| w.tier == StorageTier::Cold) {
418            push_feat(&mut features, "zstd_cold");
419        }
420    }
421    if opts.compress_sidecars && !opts.sidecars.is_empty() {
422        push_feat(&mut features, "zstd_cold");
423    }
424    manifest.features = features;
425
426    let mut extra: Vec<(String, Vec<u8>)> = Vec::new();
427    for (id, media, data) in &opts.sidecars {
428        let (codec, stored) = if opts.compress_sidecars {
429            encode_for_tier(StorageTier::Cold, data, opts.warm_block_size)?
430        } else {
431            (crate::tier::Codec::None, data.clone())
432        };
433        let rel = if media.contains("json") {
434            format!("sidecars/{id}.json")
435        } else {
436            format!("sidecars/{id}")
437        };
438        manifest.sidecars.push(SidecarRef {
439            id: id.clone(),
440            path: rel.clone(),
441            media_type: Some(media.clone()),
442            codec,
443        });
444        extra.push((rel, stored));
445    }
446
447    if let Some(pl) = &opts.placement {
448        let rel = "dist/placement.json".to_string();
449        manifest.dist = Some(DistRef {
450            placement: Some(rel.clone()),
451        });
452        extra.push((
453            rel,
454            serde_json::to_vec_pretty(pl).context("serialize placement")?,
455        ));
456    }
457
458    let (index, shard_bytes) = if weights.is_empty() {
459        manifest.weights = None;
460        (None, Vec::new())
461    } else {
462        let mut shard = Vec::new();
463        let mut tensors = Vec::new();
464        for w in weights {
465            let (codec, stored) = encode_for_tier(w.tier, &w.data, opts.warm_block_size)?;
466            let offset = shard.len() as u64;
467            let length = stored.len() as u64;
468            let raw_length = if codec == crate::tier::Codec::None {
469                None
470            } else {
471                Some(w.data.len() as u64)
472            };
473            shard.extend_from_slice(&stored);
474            tensors.push(WeightEntry {
475                name: w.name.clone(),
476                shape: w.shape.clone(),
477                scheme: w.scheme.clone(),
478                layout: w.layout.clone(),
479                shard: "weights/shards/000.pack.bin".into(),
480                offset,
481                length,
482                raw_length,
483                tier: w.tier,
484                codec,
485                rank: w.rank,
486                checksum: if opts.write_checksums {
487                    Some(checksum_hex(&w.data))
488                } else {
489                    None
490                },
491            });
492        }
493        (Some(WeightsIndex { tensors }), shard)
494    };
495
496    let io_json = IoJson {
497        inputs: graph
498            .nodes()
499            .iter()
500            .filter_map(|n| match &n.op {
501                Op::Input { name } => Some(io_entry(name, &n.shape)),
502                _ => None,
503            })
504            .collect(),
505        outputs: graph
506            .outputs
507            .iter()
508            .map(|&id| {
509                let n = graph.node(id);
510                let name = n.name.clone().unwrap_or_else(|| format!("out{}", id.0));
511                io_entry(&name, &n.shape)
512            })
513            .collect(),
514    };
515
516    Ok((manifest, index, shard_bytes, io_json, extra))
517}
518
519fn io_entry(name: &str, shape: &rlx_ir::Shape) -> IoEntry {
520    let dims: Vec<usize> = shape
521        .dims()
522        .iter()
523        .map(|d| match d {
524            rlx_ir::Dim::Static(n) => *n,
525            rlx_ir::Dim::Dynamic(_) => 0,
526        })
527        .collect();
528    IoEntry {
529        name: name.to_string(),
530        shape: dims,
531        dtype: format!("{:?}", shape.dtype()).to_lowercase(),
532    }
533}
534
535fn push_feat(feats: &mut Vec<String>, f: &str) {
536    if !feats.iter().any(|x| x == f) {
537        feats.push(f.to_string());
538    }
539}
540
541fn write_json(path: PathBuf, v: &impl Serialize) -> Result<()> {
542    let bytes = serde_json::to_vec_pretty(v)?;
543    write_bytes(path, &bytes)
544}
545
546fn write_bytes(path: PathBuf, bytes: &[u8]) -> Result<()> {
547    std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))
548}
549
550/// Infer container from path / explicit override.
551pub fn infer_container(path: &Path, explicit: Option<ContainerKind>) -> Result<ContainerKind> {
552    if let Some(c) = explicit {
553        return Ok(c);
554    }
555    if path.is_dir() {
556        return Ok(ContainerKind::Dir);
557    }
558    match path.extension().and_then(|e| e.to_str()) {
559        Some("rlxp") => Ok(ContainerKind::Flat),
560        Some("zip") => Ok(ContainerKind::Zip),
561        None => Ok(ContainerKind::Dir),
562        Some(other) => {
563            bail!("cannot infer package kind from .{other}; use .rlxp (flat), .zip, or a directory")
564        }
565    }
566}
567
568/// Back-compat alias: `true` → Zip, `false` → Dir; `None` uses path inference
569/// (`.rlxp` → Flat).
570pub fn infer_zip_from_path(path: &Path, explicit_zip: Option<bool>) -> Result<bool> {
571    match explicit_zip {
572        Some(true) => Ok(true),
573        Some(false) => Ok(false),
574        None => Ok(matches!(infer_container(path, None)?, ContainerKind::Zip)),
575    }
576}