spark_storage/kv_paging/ns.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! KV paging namespace + wire-key derivation (`ATLAS_KV_PAGING`, part of
4//! the tiered-cache consolidation).
5//!
6//! The paging peer (atlas-cache-peer) keys its KV arena purely by the u64 the
7//! client sends, so the namespace folded into every key is the ONLY thing
8//! preventing (a) two MODELS and (b) two same-model CLIENTS from silently
9//! serving each other's KV blocks. Unlike the SSM tier's content-derived
10//! `prefix_hash`, a KV `GroupKey.block` is a CLIENT-LOCAL disk-block-pool
11//! index (`HighSpeedSwap::alloc_disk_block_id`), so identical keys from two
12//! same-model clients hold UNRELATED sequence data — a model-only namespace
13//! would cross-serve with certainty (every colliding block id), not 2^-64,
14//! and a restarted client would hit its own stale pre-restart blocks. The
15//! namespace therefore folds a per-client `client_salt` (fresh random per
16//! connect; `ATLAS_KV_PAGING_SALT` pins it for tests/harness). Consequence,
17//! stated honestly: the KV paging win is peer residency + NVMe depth +
18//! capacity pooling, NOT cross-client warm hits (those need content-addressed
19//! keys — a separate chunk, same seam as the SSM decode-ns residual).
20//!
21//! The SSM `ModelFingerprint` VALUE alone is insufficient here by documented
22//! design: it excludes the KV dtype and every block-geometry field
23//! (fingerprint.rs "a future KV paging tier must fold its own dtype /
24//! block_size mix-in at its own call site"), and `GroupLayout::group_id`
25//! numbering is layout-relative (`num_blocks` changes with the GPU-memory
26//! budget). So the namespace re-folds the full layout identity alongside the
27//! fingerprint.
28//!
29//! Everything here is a DURABLE on-peer contract (the peer's swap file
30//! outlives client rebuilds): the tagged encoding is frozen behind
31//! [`KV_NS_VERSION`], and the hash primitives are vendored byte-for-byte
32//! (FNV-1a/64 + the splitmix64 finalizer, ~25 dependency-free lines) and
33//! golden-pinned against spark-model's copies (`ns_tests.rs` here,
34//! `fingerprint_tests.rs` there share frozen literals) so the two crates can
35//! never drift. spark-storage stays `ModelConfig`-free: the fingerprint
36//! arrives as a plain u64 (`ModelDims::model_fp`).
37
38use std::num::NonZeroU64;
39
40use anyhow::{Result, anyhow};
41
42use crate::group::GroupLayout;
43
44/// Bump = deliberate fleet-wide KV cache-key rotation (document it).
45pub const KV_NS_VERSION: u64 = 1;
46
47/// Domain separator folded into every KV namespace so a KV wire key is
48/// domain-separated from SSM keys IN THE KEY MATERIAL, not merely by the
49/// peer's `(kind, blob_bytes)` registry keying (which already gives each kind
50/// its own residency map + swap file — this fold makes cross-kind aliasing
51/// unrepresentable even if that registry keying were ever collapsed).
52/// Frozen; mnemonic `"KV"` + `"PAGE"` + 1. The SSM decode tier's analog is
53/// `atlas_kernels::DECODE_DOMAIN`.
54pub const KV_DOMAIN: u64 = 0x4B56_5041_4745_0001;
55
56/// Vendored FNV-1a/64 — byte-identical to spark-model's `fingerprint.rs`
57/// copy; both are pinned to the published FNV reference vectors.
58pub(crate) use atlas_tier::hash::{FNV_OFFSET, fnv1a_64};
59
60// SSOT: this was a FOURTH transcription of the splitmix64 constants — its own doc
61// comment asserted it was "byte-identical to spark-model's mix64", which is the
62// violation stating itself. One definition, in atlas_tier::hash.
63pub(crate) use atlas_tier::hash::mix64;
64
65fn put_u64(buf: &mut Vec<u8>, tag: u8, v: u64) {
66 buf.push(tag);
67 buf.extend_from_slice(&v.to_le_bytes());
68}
69
70/// Derive the KV paging namespace. FROZEN tagged encoding (injective:
71/// fixed-width `[tag][8-byte LE]` records) — any field or order change is a
72/// deliberate fleet cache flush and must bump [`KV_NS_VERSION`]:
73///
74/// | tag | field | tag | field | tag | field |
75/// |------|-----------------|------|----------------|------|---------------|
76/// | 0x00 | KV_NS_VERSION | 0x04 | block_size | 0x08 | num_kv_heads |
77/// | 0x01 | model_fp | 0x05 | head_dim | 0x09 | fs_block_size |
78/// | 0x02 | KV_DOMAIN | 0x06 | num_layers | 0x0a | group_stride |
79/// | 0x03 | elem_bytes | 0x07 | num_blocks | 0x0b | client_salt |
80///
81/// `model_fp` carries the quant identity + model_type + `ATLAS_MODEL_ID`
82/// salt (`ModelFingerprint::derive_kv` in spark-model); the geometry fields
83/// make the layout identity explicit because `group_id` numbering is
84/// layout-relative. Zero-avoidance falls back to `FNV_OFFSET` (p = 2^-64),
85/// keeping the result total — ns = 0 stays unrepresentable end-to-end.
86pub fn derive_kv_ns(
87 model_fp: u64,
88 layout: &GroupLayout,
89 elem_bytes: u32,
90 block_size: u32,
91 head_dim: u32,
92 client_salt: u64,
93) -> NonZeroU64 {
94 let mut buf = Vec::with_capacity(12 * 9);
95 for (tag, v) in [
96 (0x00u8, KV_NS_VERSION),
97 (0x01, model_fp),
98 (0x02, KV_DOMAIN),
99 (0x03, elem_bytes as u64),
100 (0x04, block_size as u64),
101 (0x05, head_dim as u64),
102 (0x06, layout.num_layers as u64),
103 (0x07, layout.num_blocks as u64),
104 (0x08, layout.num_kv_heads as u64),
105 (0x09, layout.fs_block_size),
106 (0x0a, layout.group_stride),
107 (0x0b, client_salt),
108 ] {
109 put_u64(&mut buf, tag, v);
110 }
111 let h = fnv1a_64(&buf);
112 NonZeroU64::new(h).unwrap_or(NonZeroU64::new(FNV_OFFSET).expect("FNV offset is non-zero"))
113}
114
115/// Wire key for one KV block: the namespace fold of the block's BASE dense
116/// group id — `group_id(GroupKey::new(layer, block, 0, K))`, injective across
117/// `(layer, block)` within a layout (layout identity rides in the ns, so
118/// cross-layout ids cannot alias). Same splitmix fold as the SSM tier's
119/// `PagingSnapshotStore::wire` — bijective per namespace, so no birthday risk
120/// over the dense group-id keyspace.
121pub fn wire_key(ns: NonZeroU64, base_group_id: u64) -> u64 {
122 mix64(base_group_id, ns.get())
123}
124
125/// Strict u64 parser for the env overrides (decimal or `0x`-hex). Junk is a
126/// startup ERROR, never a silent fallthrough (PCND).
127pub fn parse_u64_strict(var: &str, raw: &str) -> Result<u64> {
128 let s = raw.trim();
129 let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
130 Some(hex) => u64::from_str_radix(hex, 16),
131 None => s.parse::<u64>(),
132 };
133 parsed.map_err(|e| anyhow!("{var}={raw:?} is not a valid u64 (decimal or 0x-hex): {e}"))
134}
135
136/// `ATLAS_KV_PAGING_NS` override (env-free core): strict parse, 0 rejected —
137/// a shared peer must always be namespaced (the ns=0 passthrough is
138/// unrepresentable, mirroring the landed SSM fix). `None` ⇒ the derived ns.
139pub fn resolve_kv_ns_from(override_raw: Option<&str>, derived: NonZeroU64) -> Result<NonZeroU64> {
140 match override_raw {
141 None => Ok(derived),
142 Some(raw) => {
143 let v = parse_u64_strict("ATLAS_KV_PAGING_NS", raw)?;
144 NonZeroU64::new(v).ok_or_else(|| {
145 anyhow!(
146 "ATLAS_KV_PAGING_NS=0 is invalid: ns=0 is unrepresentable (it would \
147 cross-serve KV state on a shared peer); unset it to use the derived \
148 namespace (logged at INFO on connect)"
149 )
150 })
151 }
152 }
153}
154
155/// `ATLAS_KV_PAGING_SALT` override (env-free core): strict; `Ok(None)` ⇒ the
156/// caller generates a fresh random per-connect salt (client isolation +
157/// self-healing restart staleness — old-salt peer entries become unreachable
158/// and LRU-age out), INFO-logging it for reproducibility.
159pub fn resolve_salt_from(raw: Option<&str>) -> Result<Option<u64>> {
160 raw.map(|r| parse_u64_strict("ATLAS_KV_PAGING_SALT", r))
161 .transpose()
162}
163
164/// `ATLAS_KV_PAGING` selection (env-free core): unset or `0` ⇒ the raw dumb
165/// one-sided `RdmaKvBackend` path (client-owned allocator; its
166/// handshake is the v2 header with `blob_bytes == 0`); `1` ⇒ the peer-owned
167/// paging backend. Anything else is a startup ERROR (PCND — a typo must never
168/// silently pick a path).
169pub fn kv_paging_selected(raw: Option<&str>) -> Result<bool> {
170 match raw.map(str::trim) {
171 None | Some("0") => Ok(false),
172 Some("1") => Ok(true),
173 Some(other) => Err(anyhow!(
174 "ATLAS_KV_PAGING={other:?} is invalid: 1 = peer-owned paging KV, 0/unset = the \
175 raw one-sided KV blade"
176 )),
177 }
178}
179
180/// `ATLAS_KV_PAGING_ARENA_GB` (REQUIRED when the flag is on — no implicit
181/// default, PCND): the peer warm-arena size in GiB (fractional accepted),
182/// floored to a multiple of `block_bytes` and required to hold ≥ 1 block.
183/// The raw path sized the peer to `num_groups × group_stride` (every group
184/// a guaranteed slot); the paging arena is a deliberately smaller warm cache
185/// over the peer's NVMe swap, so the operator must choose it explicitly.
186pub fn resolve_arena_bytes_from(raw: Option<&str>, block_bytes: u64) -> Result<u64> {
187 let raw = raw.ok_or_else(|| {
188 anyhow!(
189 "ATLAS_KV_PAGING=1 requires ATLAS_KV_PAGING_ARENA_GB (peer warm-arena size in \
190 GiB, fractional ok) — explicit config or fail fast (PCND)"
191 )
192 })?;
193 let gb: f64 = raw
194 .trim()
195 .parse()
196 .map_err(|e| anyhow!("ATLAS_KV_PAGING_ARENA_GB={raw:?} is not a number: {e}"))?;
197 if !gb.is_finite() || gb <= 0.0 {
198 return Err(anyhow!(
199 "ATLAS_KV_PAGING_ARENA_GB={raw:?} must be a finite value > 0"
200 ));
201 }
202 let bb = block_bytes.max(1);
203 let arena = ((gb * (1u64 << 30) as f64) as u64 / bb) * bb;
204 if arena == 0 {
205 return Err(anyhow!(
206 "ATLAS_KV_PAGING_ARENA_GB={raw} is smaller than one KV block ({bb} B) — the \
207 warm arena must hold at least one block"
208 ));
209 }
210 Ok(arena)
211}
212
213/// Startup guard (env-free core): the cascade T1 (`ATLAS_KV_LOCAL_GB > 0`)
214/// flushes evictions DOWN via per-head `write_from_host`, which the
215/// block-record paging backend refuses — that combination must fail fast at
216/// construction (PCND), never bail mid-decode on the first T1 eviction.
217/// Returns `true` iff the incompatible combination is selected.
218pub fn cascade_conflicts_with_paging(kv_peer_set: bool, flag_raw: Option<&str>) -> Result<bool> {
219 Ok(kv_peer_set && kv_paging_selected(flag_raw)?)
220}
221
222#[cfg(test)]
223#[path = "ns_tests.rs"]
224mod tests;