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}