metrale_storage/kv_paging/ns.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: KV paging namespace and wire-key derivation, and the strict parsers for
4//! the `METRALE_KV_PAGING*` variables.
5//!
6//! Owner: storage, KV paging tier.
7//! Invariants:
8//! - Every namespace returned is non-zero: `derive_kv_ns` falls back to `FNV_OFFSET`
9//! and an override of 0 is an error.
10//! - The namespace is a pure function of its six arguments, encoded as the tagged
11//! records listed on [`derive_kv_ns`].
12//! - The parsers never default: junk input is an error.
13//!
14//! The paging peer keys its residency by the u64 the client sends
15//! (`snapshot_swap::run_paging_loop_shared`), so the namespace folded into that key
16//! is what separates two models, and two clients of one model, on one peer. A KV
17//! `GroupKey.block` is a client-local disk-block index
18//! (`HighSpeedSwap::alloc_disk_block_id`), so two clients of one model use the same
19//! block ids for unrelated data. The namespace therefore folds a per-client salt,
20//! random per connect unless `METRALE_KV_PAGING_SALT` pins it: clients share peer
21//! capacity, not cached blocks.
22//!
23//! The model fingerprint does not encode the KV cache dtype or block layout, and
24//! `GroupLayout::group_id` depends on `num_blocks`, so the namespace folds the full
25//! layout next to the fingerprint. The fingerprint arrives as a plain u64
26//! (`ModelDims::model_fp`); this crate does not depend on the model config.
27
28use std::num::NonZeroU64;
29
30use anyhow::{Result, anyhow};
31
32use crate::group::GroupLayout;
33
34/// 2026-09-25: Encoding version, hashed as the tag-0x00 record. Changing it changes
35/// every KV namespace.
36pub const KV_NS_VERSION: u64 = 1;
37
38/// 2026-09-25: Domain separator hashed into every KV namespace as the tag-0x02
39/// record, so KV keys differ from SSM keys in the key material as well as by the
40/// peer's per-`(kind, blob_bytes)` arena. ASCII `"KV"` + `"PAGE"` + 1.
41pub const KV_DOMAIN: u64 = 0x4B56_5041_4745_0001;
42
43pub(crate) use crate::tier::hash::{FNV_OFFSET, fnv1a_64};
44
45pub(crate) use crate::tier::hash::mix64;
46
47fn put_u64(buf: &mut Vec<u8>, tag: u8, v: u64) {
48 buf.push(tag);
49 buf.extend_from_slice(&v.to_le_bytes());
50}
51
52/// 2026-09-25: Derive the KV paging namespace: FNV-1a/64 over fixed-width
53/// `[tag][8-byte LE]` records. A change to the fields or their order must bump
54/// [`KV_NS_VERSION`]:
55///
56/// | tag | field | tag | field | tag | field |
57/// |------|-----------------|------|----------------|------|---------------|
58/// | 0x00 | KV_NS_VERSION | 0x04 | block_size | 0x08 | num_kv_heads |
59/// | 0x01 | model_fp | 0x05 | head_dim | 0x09 | fs_block_size |
60/// | 0x02 | KV_DOMAIN | 0x06 | num_layers | 0x0a | group_stride |
61/// | 0x03 | elem_bytes | 0x07 | num_blocks | 0x0b | client_salt |
62///
63/// `model_fp` is `ModelFingerprint::derive_kv` in metrale-model-engine: model type,
64/// quantization identity, `METRALE_MODEL_ID` and geometry. A hash of 0 is replaced by
65/// `FNV_OFFSET`.
66pub fn derive_kv_ns(
67 model_fp: u64,
68 layout: &GroupLayout,
69 elem_bytes: u32,
70 block_size: u32,
71 head_dim: u32,
72 client_salt: u64,
73) -> NonZeroU64 {
74 let mut buf = Vec::with_capacity(12 * 9);
75 for (tag, v) in [
76 (0x00u8, KV_NS_VERSION),
77 (0x01, model_fp),
78 (0x02, KV_DOMAIN),
79 (0x03, elem_bytes as u64),
80 (0x04, block_size as u64),
81 (0x05, head_dim as u64),
82 (0x06, layout.num_layers as u64),
83 (0x07, layout.num_blocks as u64),
84 (0x08, layout.num_kv_heads as u64),
85 (0x09, layout.fs_block_size),
86 (0x0a, layout.group_stride),
87 (0x0b, client_salt),
88 ] {
89 put_u64(&mut buf, tag, v);
90 }
91 let h = fnv1a_64(&buf);
92 NonZeroU64::new(h).unwrap_or(NonZeroU64::new(FNV_OFFSET).expect("FNV offset is non-zero"))
93}
94
95/// 2026-09-25: Wire key for one KV block: `mix64(base_group_id, ns)`, the fold the SSM
96/// tier's `PagingSnapshotStore::wire` also uses. For a fixed namespace it is a
97/// bijection, so distinct group ids never share a key. The caller passes the id of
98/// the block's first group, `group_id(GroupKey::new(layer, block, 0, K))`.
99pub fn wire_key(ns: NonZeroU64, base_group_id: u64) -> u64 {
100 mix64(base_group_id, ns.get())
101}
102
103/// 2026-09-25: Parse a u64 in decimal or `0x`/`0X` hex, after trimming; anything
104/// else is an error naming `var`.
105pub fn parse_u64_strict(var: &str, raw: &str) -> Result<u64> {
106 let s = raw.trim();
107 let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
108 Some(hex) => u64::from_str_radix(hex, 16),
109 None => s.parse::<u64>(),
110 };
111 parsed.map_err(|e| anyhow!("{var}={raw:?} is not a valid u64 (decimal or 0x-hex): {e}"))
112}
113
114/// 2026-09-25: Resolve the `METRALE_KV_PAGING_NS` value: `None` returns `derived`; a
115/// value is parsed strictly and 0 is an error.
116pub fn resolve_kv_ns_from(override_raw: Option<&str>, derived: NonZeroU64) -> Result<NonZeroU64> {
117 match override_raw {
118 None => Ok(derived),
119 Some(raw) => {
120 let v = parse_u64_strict("METRALE_KV_PAGING_NS", raw)?;
121 NonZeroU64::new(v).ok_or_else(|| {
122 anyhow!(
123 "METRALE_KV_PAGING_NS=0 is invalid: ns=0 is unrepresentable (it would \
124 cross-serve KV state on a shared peer); unset it to use the derived \
125 namespace (logged at INFO on connect)"
126 )
127 })
128 }
129 }
130}
131
132/// 2026-09-25: Resolve the `METRALE_KV_PAGING_SALT` value: `None` returns `Ok(None)`,
133/// and the caller draws a random salt; a value is parsed strictly.
134pub fn resolve_salt_from(raw: Option<&str>) -> Result<Option<u64>> {
135 raw.map(|r| parse_u64_strict("METRALE_KV_PAGING_SALT", r))
136 .transpose()
137}
138
139/// 2026-09-25: Resolve the `METRALE_KV_PAGING` value after trimming: unset or `0`
140/// is `false` (the raw one-sided `RdmaKvBackend`), `1` is `true` (the paging
141/// backend), anything else is an error.
142pub fn kv_paging_selected(raw: Option<&str>) -> Result<bool> {
143 match raw.map(str::trim) {
144 None | Some("0") => Ok(false),
145 Some("1") => Ok(true),
146 Some(other) => Err(anyhow!(
147 "METRALE_KV_PAGING={other:?} is invalid: 1 = peer-owned paging KV, 0/unset = the \
148 raw one-sided KV blade"
149 )),
150 }
151}
152
153/// 2026-09-25: Resolve `METRALE_KV_PAGING_ARENA_GB`, the peer arena size in GiB
154/// (fractional accepted), floored to a multiple of `block_bytes`. Unset, non-finite,
155/// not above 0, or smaller than one block is an error. The raw backend sizes its
156/// arena to hold every group (`RdmaKvBackend::connect`); the paging arena has no
157/// such size to derive, so it must be given.
158pub fn resolve_arena_bytes_from(raw: Option<&str>, block_bytes: u64) -> Result<u64> {
159 let raw = raw.ok_or_else(|| {
160 anyhow!(
161 "METRALE_KV_PAGING=1 requires METRALE_KV_PAGING_ARENA_GB (peer warm-arena size in \
162 GiB, fractional ok) — explicit config or fail fast (PCND)"
163 )
164 })?;
165 let gb: f64 = raw
166 .trim()
167 .parse()
168 .map_err(|e| anyhow!("METRALE_KV_PAGING_ARENA_GB={raw:?} is not a number: {e}"))?;
169 if !gb.is_finite() || gb <= 0.0 {
170 return Err(anyhow!(
171 "METRALE_KV_PAGING_ARENA_GB={raw:?} must be a finite value > 0"
172 ));
173 }
174 let bb = block_bytes.max(1);
175 let arena = ((gb * (1u64 << 30) as f64) as u64 / bb) * bb;
176 if arena == 0 {
177 return Err(anyhow!(
178 "METRALE_KV_PAGING_ARENA_GB={raw} is smaller than one KV block ({bb} B) — the \
179 warm arena must hold at least one block"
180 ));
181 }
182 Ok(arena)
183}
184
185/// 2026-09-25: `true` when `kv_peer_set` and `flag_raw` selects the paging backend;
186/// an invalid `flag_raw` is an error. `CascadeBackend` evicts through per-head
187/// `write_from_host`, which `KvPagingBackend` refuses.
188pub fn cascade_conflicts_with_paging(kv_peer_set: bool, flag_raw: Option<&str>) -> Result<bool> {
189 Ok(kv_peer_set && kv_paging_selected(flag_raw)?)
190}
191
192#[cfg(test)]
193#[path = "ns_tests.rs"]
194mod tests;