Skip to main content

rhdl_ubft/
register.rs

1//! Single-writer registers: recovery layer 1 (docs/rhdl_ubft-recovery-design.md).
2//!
3//! **Problem.** Consistent broadcast needs memory that each replica owns,
4//! every replica can read, and a read can't see half a write. RDMA writes
5//! of several words reach memory in pieces, so a reader racing a writer can
6//! see a mix of an old and a new value.
7//!
8//! **First attempt, and why it failed.** The first version followed uBFT
9//! (LPD-EPFL/ubft `504eba0`, `swmr/`): a checksum over incarnation and value,
10//! and a read was valid when the checksum matched. Atlas found two payloads
11//! with the same checksum at the same incarnation, so a read mixing their
12//! words looks complete. That works with whole-word tearing, and with whole
13//! 64-byte beats (tests/register_integrity.rs). No checksum fixes this: the
14//! payload is partly chosen by others (a broadcaster's signature, stored in
15//! an honest receiver's register), so anyone who knows the function can aim
16//! for a collision. uBFT's XXH3 has the same weakness in principle.
17//!
18//! **Idea.** Tag every word with the write it belongs to. A subslot is an
19//! incarnation word, then the payload packed 24 bits per word, each word
20//! carrying the incarnation's low 8 bits on top:
21//!
22//! ```text
23//! word 0:  incarnation (32 bits)
24//! word j:  [tag(incarnation) | 24 payload bits]      j = 1 .. S − 1
25//! tag(i) = 0x80 | (i & 0x7f)
26//! ```
27//!
28//! A read is valid only if every word's tag equals `tag` of the incarnation
29//! word. The tag's top bit is always set, so zeroed memory never matches a
30//! tag. (An independent review found the first version, which used the
31//! incarnation's low byte, accepted an all-zero subslot under incarnation
32//! 256.) A read mixing words of two writes has mismatched tags whatever
33//! the payload, so nobody can choose values to get one through. As in uBFT,
34//! there are two subslots, the incarnation's parity picks the one to write,
35//! and the reader keeps the valid subslot with the higher incarnation.
36//!
37//! **Assumptions** (stated here; they must be enforced by future layer-2
38//! engines, which do not exist yet; these kernels cannot enforce them):
39//! - a 32-bit aligned word is written and read whole (the AXI strobe unit);
40//! - tags repeat every 128 incarnations (64 writes to one subslot), so the
41//!   incarnation must advance by exactly one per write (no caller-chosen
42//!   jumps), and a read must not overlap more than one write. The writer
43//!   starts a write at least `COOLDOWN` cycles after the previous one became
44//!   visible, and a reader accepts a sample only if its whole span, queues
45//!   included, is strictly less than `COOLDOWN` cycles (the strict audit's
46//!   bound; then mixed subslots differ by two generations). Without those
47//!   engines the tags alias: a mix of writes 1 and 129 (or 257) of one
48//!   subslot passes (review witnesses, agent/ubft-register-generation
49//!   0f323f3). uBFT has the same kind
50//!   of timing rule (a 1 ms write cooldown).
51//! - **Open (review finding):** with replicated registers, a memory host
52//!   that missed 128 or more updates can hold a subslot whose mix of an old
53//!   and a new write shares a tag. Logical incarnations alone cannot rule
54//!   that out. The fix planned for layer 2 is a physical generation per host,
55//!   stamped by that host's trusted receive path on every applied write and
56//!   checked by readers, distinct from the logical incarnation.
57//!
58//! Incarnation 0 means "never written". Cleared memory has incarnation 0
59//! and so never reads as a value.
60//!
61//! **Cost.** Payload density is 24/32: a 64-byte signature takes 22 words
62//! plus the incarnation word.
63
64use rhdl::prelude::*;
65
66/// Payload bits per tagged word.
67pub const CHUNK_BITS: usize = 24;
68
69/// Words in a subslot holding `w` 32-bit payload words: the incarnation
70/// word plus `⌈32w / 24⌉` tagged words.
71pub const fn subslot_words(w: usize) -> usize {
72    1 + (32 * w).div_ceil(CHUNK_BITS)
73}
74
75/// A subslot for `incarnation` holding `v`. `S` must be [`subslot_words`]`(W)`.
76#[kernel]
77pub fn pack_subslot<const W: usize, const S: usize>(incarnation: b32, v: [b32; W]) -> [b32; S] {
78    let tag: b32 = (b32(0x80) | (incarnation & b32(0x7f))) << b32(24);
79    let mut out = [b32(0); S];
80    out[0] = incarnation;
81    // (Kernel loops need a constant end, and kernels take shifts and
82    // masks, not / or %.)
83    for jj in 1..S {
84        // Chunk j is payload bits 24j .. 24j + 24.
85        let j = jj - 1;
86        let a = (24 * j) >> 5;
87        let o = (24 * j) & 31;
88        let lo: b64 = v[a].resize::<64>() >> b64(o as u128);
89        let hi: b64 = if a + 1 < W {
90            v[a + 1].resize::<64>() << b64((32 - o) as u128)
91        } else {
92            b64(0)
93        };
94        let chunk: b32 = ((lo | hi) & b64(0xff_ffff)).resize();
95        out[1 + j] = tag | chunk;
96    }
97    out
98}
99
100/// Which subslot a write of `incarnation` goes to (0 or 1, by parity).
101#[kernel]
102pub fn subslot_of(incarnation: b32) -> b1 {
103    incarnation.resize()
104}
105
106/// One subslot as read: whether every word belongs to one write, and the
107/// payload.
108#[kernel]
109pub fn unpack_subslot<const W: usize, const S: usize>(s: [b32; S]) -> (bool, [b32; W]) {
110    let tag: b32 = b32(0x80) | (s[0] & b32(0x7f));
111    let mut ok = s[0] != b32(0);
112    for j in 1..S {
113        if (s[j] >> b32(24)) != tag {
114            ok = false;
115        }
116    }
117    // Scatter chunk j (payload bits 24j .. 24j + 24) into the one or two
118    // payload words it covers.
119    let mut acc = [b64(0); W];
120    for jj in 1..S {
121        let j = jj - 1;
122        let a = (24 * j) >> 5;
123        let o = (24 * j) & 31;
124        let chunk: b64 = (s[1 + j] & b32(0xff_ffff)).resize();
125        if a < W {
126            acc[a] = acc[a] | (chunk << b64(o as u128));
127        }
128        if o > 8 && a + 1 < W {
129            acc[a + 1] = acc[a + 1] | (chunk >> b64((32 - o) as u128));
130        }
131    }
132    let mut v = [b32(0); W];
133    for i in 0..W {
134        v[i] = acc[i].resize();
135    }
136    (ok, v)
137}
138
139/// Value selection and fault observation answer different questions. Identical
140/// valid subslots can supply a value while still revealing a same-host tie.
141#[derive(Clone, Copy, Debug, PartialEq, Digital)]
142pub struct RegisterInspection<const W: usize> {
143    pub valid: bool,
144    pub physical: b32,
145    pub payload: [b32; W],
146    /// Both subslots validate and claim the same physical generation.
147    pub same_host_tie: bool,
148    /// A same-host tie also disagrees on at least one decoded payload word.
149    pub conflicting_payloads: bool,
150}
151
152/// Inspect one host's pair without losing the distinction between a usable
153/// identical copy and a contradictory pair. Never compare different hosts here.
154#[kernel]
155pub fn inspect_register<const W: usize, const S: usize>(
156    a: [b32; S],
157    b: [b32; S],
158) -> RegisterInspection<W> {
159    let (ok_a, va) = unpack_subslot::<W, S>(a);
160    let (ok_b, vb) = unpack_subslot::<W, S>(b);
161    let tie = ok_a && ok_b && a[0] == b[0];
162    let mut differs = false;
163    for i in 0..W {
164        if va[i] != vb[i] {
165            differs = true;
166        }
167    }
168    let mut result = RegisterInspection::<W> {
169        valid: false,
170        physical: b32(0),
171        payload: [b32(0); W],
172        same_host_tie: tie,
173        conflicting_payloads: tie && differs,
174    };
175    if !(tie && differs) {
176        if ok_a && (!ok_b || a[0] > b[0]) {
177            result.valid = true;
178            result.physical = a[0];
179            result.payload = va;
180        } else if ok_b {
181            result.valid = true;
182            result.physical = b[0];
183            result.payload = vb;
184        }
185    }
186    result
187}
188
189/// The register read from its two subslots `a` (subslot 0) and `b`: whether
190/// either is valid, the higher valid incarnation, and its value.
191/// Equal valid generations with different decoded payloads are ambiguous and
192/// return invalid/default. Identical copies remain acceptable. This comparison
193/// is within one host's register; equal generations across hosts are normal.
194#[kernel]
195pub fn select_register<const W: usize, const S: usize>(
196    a: [b32; S],
197    b: [b32; S],
198) -> (bool, b32, [b32; W]) {
199    // Preserve the approved R-32 interface for callers that only select values.
200    let inspected = inspect_register::<W, S>(a, b);
201    (inspected.valid, inspected.physical, inspected.payload)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    const W: usize = 4;
209    const S: usize = 7;
210
211    fn v(x: u32) -> [b32; W] {
212        std::array::from_fn(|i| {
213            b32(u128::from(
214                x.wrapping_mul(0x9e37_79b9)
215                    .wrapping_add(i as u32 * 0x0101_0101),
216            ))
217        })
218    }
219
220    fn write(reg: &mut [[b32; S]; 2], inc: u32, x: u32) {
221        let s = pack_subslot::<W, S>(b32(u128::from(inc)), v(x));
222        reg[subslot_of(b32(u128::from(inc))).raw() as usize] = s;
223    }
224
225    #[test]
226    fn sizes() {
227        assert_eq!(subslot_words(4), S);
228        assert_eq!(subslot_words(16), 23);
229        assert_eq!(subslot_words(45), 61);
230    }
231
232    /// Lower the real selector, including its ambiguity rejection, in the
233    /// synchronous compilation mode used by the register controllers.
234    #[test]
235    fn ambiguity_policy_lowers_synchronously() -> anyhow::Result<()> {
236        // Embedded at compilation, not read from the live tree when the test
237        // runs. This fingerprints this selector source, not the whole crate.
238        let source_fingerprint = include_bytes!("register.rs")
239            .iter()
240            .fold(0xcbf29ce484222325u64, |hash, byte| {
241                (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
242            });
243        println!("REGISTER_SOURCE_FNV={source_fingerprint:016x}");
244        let _ = compile_design::<select_register<W, S>>(CompilationMode::Synchronous)?;
245        Ok(())
246    }
247
248    /// Packing round-trips every bit of the payload.
249    #[test]
250    fn pack_round_trips() {
251        for x in [0u32, 1, 0xdead_beef, u32::MAX] {
252            for bit in 0..(32 * W) {
253                let mut p = v(x);
254                p[bit / 32] = p[bit / 32] ^ b32(1 << (bit % 32));
255                let (ok, got) = unpack_subslot::<W, S>(pack_subslot::<W, S>(b32(5), p));
256                assert!(ok);
257                assert_eq!(got, p);
258            }
259        }
260    }
261
262    /// A never-written register reads as empty; then each write is seen,
263    /// newest first, whichever subslot it went to.
264    #[test]
265    fn reads_follow_writes() {
266        let mut reg = [[b32(0); S]; 2];
267        assert!(!select_register::<W, S>(reg[0], reg[1]).0);
268        for inc in 1..=5u32 {
269            write(&mut reg, inc, 100 + inc);
270            let (ok, got_inc, val) = select_register::<W, S>(reg[0], reg[1]);
271            assert!(ok);
272            assert_eq!(got_inc.raw(), u128::from(inc));
273            assert_eq!(val, v(100 + inc));
274        }
275    }
276
277    /// Every way to mix words of two writes of one subslot is rejected, and
278    /// the reader falls back to the other subslot's complete value. Checked
279    /// for every subset of words taken from the older write.
280    #[test]
281    fn any_mix_of_two_writes_falls_back_to_the_previous_value() {
282        let mut reg = [[b32(0); S]; 2];
283        write(&mut reg, 1, 11);
284        write(&mut reg, 2, 22);
285        let fresh = pack_subslot::<W, S>(b32(3), v(33));
286        for mask in 1u32..(1 << S) - 1 {
287            let mut s = fresh;
288            for k in 0..S {
289                if mask >> k & 1 == 1 {
290                    s[k] = reg[1][k];
291                }
292            }
293            let (ok, inc, val) = select_register::<W, S>(reg[0], s);
294            assert!(ok);
295            assert_eq!((inc.raw(), val), (2, v(22)), "mask {mask:#b}");
296        }
297    }
298
299    /// Review finding: an all-zero subslot under an incarnation whose low
300    /// bits are zero is not a value.
301    #[test]
302    fn zeroed_memory_never_matches_a_tag() {
303        for inc in [128u32, 256, 0x8000_0000, 1, 0x7f] {
304            let mut s = [b32(0); S];
305            s[0] = b32(u128::from(inc));
306            assert!(!unpack_subslot::<W, S>(s).0, "incarnation {inc}");
307            assert!(!select_register::<W, S>(s, [b32(0); S]).0);
308        }
309    }
310
311    /// Atlas's chosen-payload witness against the old checksum: two
312    /// payloads that collided, mixed word by word, are rejected here.
313    #[test]
314    fn chosen_colliding_payloads_cannot_mix() {
315        let old = [b32(0x1b3), b32(0x0100_0366), b32(0), b32(0)];
316        let new = [b32(0x1b2), b32(0x0080_0366), b32(1), b32(1)];
317        let prior = [b32(9); 4];
318        let old_slot = pack_subslot::<W, S>(b32(1), old);
319        let current = pack_subslot::<W, S>(b32(2), prior);
320        let new_slot = pack_subslot::<W, S>(b32(3), new);
321        for k in 0..S {
322            let mut torn = new_slot;
323            torn[k] = old_slot[k];
324            let (ok, _, value) = select_register::<W, S>(current, torn);
325            assert!(ok);
326            assert_eq!(value, prior, "word {k}");
327        }
328    }
329
330    /// The kernels as hardware: the RHDL VM and the generated Verilog agree
331    /// with the native code on fresh, mixed and empty registers.
332    #[test]
333    fn kernels_match_vm_and_generated_verilog() -> anyhow::Result<()> {
334        use rhdl::core::sim::testbench::kernel::test_kernel_vm_and_verilog_synchronous;
335        // These plain Digital values belong to one synchronous register step.
336        // Keep the same VM/RTL/netlist checks as the lowering control's mode;
337        // this fixture does not model asynchronous clock-domain crossings.
338        let mut cases = Vec::new();
339        let empty = [b32(0); S];
340        for inc in [1u32, 2, 3, 0xffff_fffe] {
341            let a = pack_subslot::<W, S>(b32(u128::from(inc)), v(inc));
342            let b = pack_subslot::<W, S>(b32(u128::from(inc + 1)), v(inc + 1));
343            let mut torn = b;
344            torn[S - 1] = a[S - 1];
345            cases.push((a, b));
346            cases.push((b, a));
347            cases.push((a, torn));
348            cases.push((empty, a));
349        }
350        cases.push((empty, empty));
351        test_kernel_vm_and_verilog_synchronous::<select_register<W, S>, _, _, _>(
352            select_register::<W, S>,
353            cases.into_iter(),
354        )?;
355        let packs = (1u32..6).map(|i| (b32(u128::from(i)), v(i)));
356        test_kernel_vm_and_verilog_synchronous::<pack_subslot<W, S>, _, _, _>(
357            pack_subslot::<W, S>,
358            packs,
359        )?;
360        Ok(())
361    }
362}