Skip to main content

rlx_pkg/
tier.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//! Storage tiers and codecs for hybrid packs (hot / warm / cold).
6//!
7//! | Tier | [`Codec`] | Access |
8//! |------|-----------|--------|
9//! | [`StorageTier::Hot`] | [`Codec::None`] | [`crate::Package::tensor_mmap`] |
10//! | [`StorageTier::Warm`] | [`Codec::ZstdBlocks`] | [`crate::Package::tensor_bytes`] / [`decode_zstd_block_at`] |
11//! | [`StorageTier::Cold`] | [`Codec::Zstd`] | sidecars via [`crate::Package::sidecar`] |
12//!
13//! Warm is for rarely touched host blobs — keep quantized LLM weights **hot**.
14
15use anyhow::{Context, Result, bail};
16use serde::{Deserialize, Serialize};
17
18/// Access class for a blob inside a flat package.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum StorageTier {
22    /// Raw bytes in the mmap window — GEMM / dequant hot path.
23    #[default]
24    Hot,
25    /// Block-compressed (seekable-ish); decompress blocks on demand.
26    Warm,
27    /// Whole-blob compression; inflate once (sidecars, reports).
28    Cold,
29}
30
31/// On-disk encoding of a blob's payload bytes.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Codec {
35    /// Uncompressed (hot tier).
36    #[default]
37    None,
38    /// Single zstd frame covering the whole blob (cold).
39    Zstd,
40    /// Concatenated zstd frames with a small block header (warm).
41    ZstdBlocks,
42}
43
44impl StorageTier {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Hot => "hot",
48            Self::Warm => "warm",
49            Self::Cold => "cold",
50        }
51    }
52}
53
54impl Codec {
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Self::None => "none",
58            Self::Zstd => "zstd",
59            Self::ZstdBlocks => "zstd_blocks",
60        }
61    }
62}
63
64/// Default uncompressed block size for warm `zstd_blocks` (1 MiB).
65pub const DEFAULT_WARM_BLOCK: u32 = 1 << 20;
66
67const ZBLK_MAGIC: &[u8; 4] = b"ZBLK";
68
69/// Encode a warm blob: header + per-block zstd frames.
70pub fn encode_zstd_blocks(raw: &[u8], block_size: u32) -> Result<Vec<u8>> {
71    if block_size == 0 {
72        bail!("warm block_size must be > 0");
73    }
74    let block_size = block_size as usize;
75    let mut out = Vec::new();
76    out.extend_from_slice(ZBLK_MAGIC);
77    out.extend_from_slice(&(block_size as u32).to_le_bytes());
78    out.extend_from_slice(&(raw.len() as u64).to_le_bytes());
79    let n_blocks = raw.len().div_ceil(block_size);
80    out.extend_from_slice(&(n_blocks as u32).to_le_bytes());
81    for chunk in raw.chunks(block_size) {
82        let comp = z_encode_all(chunk, 3)?;
83        out.extend_from_slice(&(comp.len() as u32).to_le_bytes());
84        out.extend_from_slice(&comp);
85    }
86    Ok(out)
87}
88
89/// Decode a warm `ZBLK` blob to owned raw bytes (sequential).
90pub fn decode_zstd_blocks(comp: &[u8]) -> Result<Vec<u8>> {
91    decode_zstd_blocks_impl(comp, false)
92}
93
94/// Decode warm blocks in parallel via rayon (better for large tensors).
95pub fn decode_zstd_blocks_parallel(comp: &[u8]) -> Result<Vec<u8>> {
96    decode_zstd_blocks_impl(comp, true)
97}
98
99/// Streaming zstd decode bounded to `expected + 1` bytes, then required to equal
100/// `expected`. A decompression bomb (a small frame declaring a huge content
101/// size) is stopped at `expected + 1` — peak memory is tied to the block's
102/// deterministic declared length, not the attacker's frame header — and then
103/// rejected. Replaces `zstd::decode_all`, which has no output cap.
104// zstd is C-backed (won't build for wasm32). These thin wrappers keep it behind
105// `cfg`: native links zstd; wasm returns a clear error (the browser path loads
106// uncompressed / hot-tier packages, so the compressed codec is never invoked).
107#[cfg(not(target_arch = "wasm32"))]
108fn z_encode_all(data: &[u8], level: i32) -> Result<Vec<u8>> {
109    zstd::encode_all(data, level).context("zstd compress")
110}
111#[cfg(not(target_arch = "wasm32"))]
112fn z_decode_all(data: &[u8]) -> Result<Vec<u8>> {
113    zstd::decode_all(data).context("zstd decompress")
114}
115#[cfg(target_arch = "wasm32")]
116fn z_encode_all(_data: &[u8], _level: i32) -> Result<Vec<u8>> {
117    bail!("rlx-pkg: zstd compression unavailable on wasm32 (use uncompressed/hot-tier packages)")
118}
119#[cfg(target_arch = "wasm32")]
120fn z_decode_all(_data: &[u8]) -> Result<Vec<u8>> {
121    bail!("rlx-pkg: zstd decompression unavailable on wasm32 (use uncompressed/hot-tier packages)")
122}
123
124#[cfg(not(target_arch = "wasm32"))]
125fn zstd_decode_bounded(input: &[u8], expected: usize) -> Result<Vec<u8>> {
126    use std::io::Read;
127    let mut dec = zstd::Decoder::new(input).context("init zstd block decoder")?;
128    let mut out = Vec::new();
129    dec.by_ref()
130        .take(expected as u64 + 1)
131        .read_to_end(&mut out)
132        .context("zstd decode block")?;
133    if out.len() != expected {
134        bail!(
135            "warm block decoded len {} != expected {expected}",
136            out.len()
137        );
138    }
139    Ok(out)
140}
141#[cfg(target_arch = "wasm32")]
142fn zstd_decode_bounded(_input: &[u8], _expected: usize) -> Result<Vec<u8>> {
143    bail!("rlx-pkg: zstd block decode unavailable on wasm32 (use uncompressed/hot-tier packages)")
144}
145
146fn decode_zstd_blocks_impl(comp: &[u8], parallel: bool) -> Result<Vec<u8>> {
147    if comp.len() < 4 + 4 + 8 + 4 {
148        bail!("warm blob too short");
149    }
150    if &comp[..4] != ZBLK_MAGIC {
151        bail!("warm blob missing ZBLK magic");
152    }
153    let block_size = u32::from_le_bytes(comp[4..8].try_into().unwrap()) as usize;
154    let raw_len = u64::from_le_bytes(comp[8..16].try_into().unwrap()) as usize;
155    let n_blocks = u32::from_le_bytes(comp[16..20].try_into().unwrap()) as usize;
156    // Validate the encoder's chunking invariant (see `encode_zstd_blocks`):
157    // block_size > 0, n_blocks == ceil(raw_len / block_size), and every block
158    // decompresses to exactly block_size except the last (the remainder). This
159    // makes each block's output size DETERMINISTIC (not attacker-chosen), so the
160    // bounded decode below caps peak memory at ~raw_len rather than a bomb frame.
161    if raw_len == 0 {
162        if n_blocks != 0 {
163            bail!("warm blob: raw_len 0 but claims {n_blocks} blocks");
164        }
165    } else {
166        if block_size == 0 {
167            bail!("warm blob: block_size 0 with nonzero raw_len");
168        }
169        if n_blocks != raw_len.div_ceil(block_size) {
170            bail!(
171                "warm blob: n_blocks {n_blocks} inconsistent with raw_len {raw_len} / block_size {block_size}"
172            );
173        }
174    }
175    // Decompression-bomb guard: a legit warm blob's uncompressed size is at most
176    // a small multiple of its compressed bytes; reject an implausible ratio up
177    // front (before any decode) so a tiny blob can't declare a giant output.
178    const MAX_WARM_DECOMPRESS_RATIO: usize = 1000;
179    if raw_len > comp.len().saturating_mul(MAX_WARM_DECOMPRESS_RATIO) {
180        bail!(
181            "warm blob decompression ratio too high (raw_len {raw_len} vs {} compressed bytes) — possible bomb",
182            comp.len()
183        );
184    }
185    // `n_blocks` is an untrusted u32 from the blob header; each block needs at
186    // least a 4-byte length prefix, so the real count can't exceed
187    // `(comp.len() - 20) / 4`. Clamp the capacity hint to that (the loop below
188    // still self-bounds by bailing on truncation) so a tiny blob claiming
189    // ~4 billion blocks can't force a multi-GB allocation.
190    let mut ranges = Vec::with_capacity(n_blocks.min(comp.len().saturating_sub(20) / 4));
191    let mut pos = 20usize;
192    for i in 0..n_blocks {
193        if pos + 4 > comp.len() {
194            bail!("warm blob truncated at block {i} header");
195        }
196        let clen = u32::from_le_bytes(comp[pos..pos + 4].try_into().unwrap()) as usize;
197        pos += 4;
198        if pos + clen > comp.len() {
199            bail!("warm blob truncated at block {i} data");
200        }
201        ranges.push((pos, pos + clen));
202        pos += clen;
203    }
204
205    // Deterministic expected output length per block (from the validated
206    // invariant): every block is `block_size` except the last (the remainder).
207    // `block_size * (n_blocks - 1) <= raw_len` holds, so no underflow.
208    let expected = |i: usize| -> usize {
209        if i + 1 < n_blocks {
210            block_size
211        } else {
212            raw_len - block_size * (n_blocks - 1)
213        }
214    };
215    let pieces: Result<Vec<Vec<u8>>> = if parallel && n_blocks > 1 {
216        use rayon::prelude::*;
217        ranges
218            .par_iter()
219            .enumerate()
220            .map(|(i, (a, b))| {
221                zstd_decode_bounded(&comp[*a..*b], expected(i))
222                    .with_context(|| format!("zstd decompress block {i}"))
223            })
224            .collect()
225    } else {
226        ranges
227            .iter()
228            .enumerate()
229            .map(|(i, (a, b))| {
230                zstd_decode_bounded(&comp[*a..*b], expected(i))
231                    .with_context(|| format!("zstd decompress block {i}"))
232            })
233            .collect()
234    };
235    let pieces = pieces?;
236    // Size the output from the bytes we actually decoded, not the untrusted
237    // `raw_len` header (a hostile blob could set `raw_len` to ~u64::MAX to
238    // force a giant allocation); `raw_len` is still validated below.
239    let out_len: usize = pieces.iter().map(|p| p.len()).sum();
240    let mut out = Vec::with_capacity(out_len);
241    for p in pieces {
242        out.extend_from_slice(&p);
243    }
244    if out.len() != raw_len {
245        bail!("warm decode length {} != declared {raw_len}", out.len());
246    }
247    Ok(out)
248}
249
250/// Decode one warm block by index without inflating the whole tensor.
251pub fn decode_zstd_block_at(comp: &[u8], block_index: usize) -> Result<Vec<u8>> {
252    if comp.len() < 20 || &comp[..4] != ZBLK_MAGIC {
253        bail!("not a ZBLK warm blob");
254    }
255    let n_blocks = u32::from_le_bytes(comp[16..20].try_into().unwrap()) as usize;
256    if block_index >= n_blocks {
257        bail!("block_index {block_index} out of range ({n_blocks})");
258    }
259    let mut pos = 20usize;
260    for i in 0..=block_index {
261        if pos + 4 > comp.len() {
262            bail!("warm blob truncated");
263        }
264        let clen = u32::from_le_bytes(comp[pos..pos + 4].try_into().unwrap()) as usize;
265        pos += 4;
266        if pos + clen > comp.len() {
267            bail!("warm blob truncated");
268        }
269        if i == block_index {
270            return z_decode_all(&comp[pos..pos + clen]);
271        }
272        pos += clen;
273    }
274    unreachable!()
275}
276
277pub fn encode_zstd(raw: &[u8]) -> Result<Vec<u8>> {
278    z_encode_all(raw, 3)
279}
280
281pub fn decode_zstd(comp: &[u8]) -> Result<Vec<u8>> {
282    z_decode_all(comp)
283}
284
285/// Decode payload bytes according to codec → owned raw.
286pub fn decode_payload(codec: Codec, stored: &[u8]) -> Result<Vec<u8>> {
287    match codec {
288        Codec::None => Ok(stored.to_vec()),
289        Codec::Zstd => decode_zstd(stored),
290        Codec::ZstdBlocks => decode_zstd_blocks(stored),
291    }
292}
293
294/// Like [`decode_payload`] but parallelizes warm block inflate.
295pub fn decode_payload_parallel(codec: Codec, stored: &[u8]) -> Result<Vec<u8>> {
296    match codec {
297        Codec::None => Ok(stored.to_vec()),
298        Codec::Zstd => decode_zstd(stored),
299        Codec::ZstdBlocks => decode_zstd_blocks_parallel(stored),
300    }
301}
302
303/// Encode raw bytes for a tier (picks codec).
304pub fn encode_for_tier(tier: StorageTier, raw: &[u8], warm_block: u32) -> Result<(Codec, Vec<u8>)> {
305    match tier {
306        StorageTier::Hot => Ok((Codec::None, raw.to_vec())),
307        StorageTier::Warm => Ok((Codec::ZstdBlocks, encode_zstd_blocks(raw, warm_block)?)),
308        StorageTier::Cold => Ok((Codec::Zstd, encode_zstd(raw)?)),
309    }
310}
311
312/// xxh3-64 hex checksum of raw (uncompressed) bytes.
313pub fn checksum_hex(raw: &[u8]) -> String {
314    format!("{:016x}", xxhash_rust::xxh3::xxh3_64(raw))
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    /// Warm ZBLK roundtrip still works after the bounded-decode hardening —
322    /// incl. a partial trailing block (2 full blocks + a 100-byte remainder),
323    /// via both the parallel and serial decode paths.
324    #[test]
325    fn warm_roundtrip_multiblock() {
326        let raw: Vec<u8> = (0..(256 * 2 + 100)).map(|i| (i % 251) as u8).collect();
327        let enc = encode_zstd_blocks(&raw, 256).unwrap();
328        assert_eq!(decode_zstd_blocks_parallel(&enc).unwrap(), raw);
329        assert_eq!(decode_zstd_blocks(&enc).unwrap(), raw);
330    }
331
332    /// A tampered header must be rejected WITHOUT allocating the declared size:
333    /// a `raw_len` bomb (ratio/invariant guard) and an inconsistent `n_blocks`.
334    #[test]
335    fn warm_rejects_bomb_and_tampered_headers() {
336        let raw: Vec<u8> = (0..1000u32).map(|i| (i % 7) as u8).collect();
337        let enc = encode_zstd_blocks(&raw, 256).unwrap();
338
339        // raw_len (bytes 8..16) → u64::MAX: must error, not try to allocate.
340        let mut bomb = enc.clone();
341        bomb[8..16].copy_from_slice(&u64::MAX.to_le_bytes());
342        assert!(decode_zstd_blocks(&bomb).is_err());
343
344        // n_blocks (bytes 16..20) inconsistent with raw_len/block_size.
345        let mut bad_nb = enc.clone();
346        bad_nb[16..20].copy_from_slice(&9999u32.to_le_bytes());
347        assert!(decode_zstd_blocks(&bad_nb).is_err());
348    }
349}