ferrum_models/common/paged_pool.rs
1//! Multi-sequence paged-KV pool — Phase 4 of Metal paged attention.
2//!
3//! Replaces the per-cache_id `KvCache` allocation model with a
4//! shared-pool architecture matching vLLM / mistral.rs:
5//!
6//! - **One pool per layer** holds K and V for *all* concurrent sequences.
7//! Sized to `MAX_TOTAL_BLOCKS × num_kv_heads × block_size × head_dim`.
8//! - **Per-cache_id state** ([`PagedSeqState`]) carries that sequence's
9//! logical → physical block mapping (`block_table`) plus its current
10//! length (`len`). Multiple cache_ids can index into the same pool
11//! without colliding because their block_tables point at disjoint
12//! physical blocks (or shared ones, if prefix-caching is enabled
13//! later).
14//! - **[`BlockAllocator`]** is a free-list owning physical block indices.
15//! `allocate` pops, `free` pushes back. Out-of-memory surfaces as
16//! `Result::Err` so the scheduler can refuse the request rather than
17//! panicking deep in the model forward.
18//!
19//! ## Ref counting (Phase 1 of block-level prefix cache)
20//!
21//! Each physical block has a `u16` ref count. `allocate` / `allocate_n`
22//! create a block with `ref_count = 1`. `free` decrements; when the count
23//! reaches zero the block returns to the free list. `acquire(block)`
24//! increments — used by the prefix cache when another sequence wants to
25//! share an already-resident block.
26//!
27//! Backwards compatible: pre-prefix-cache callers don't touch `acquire`,
28//! so every block stays at ref=1 and `free` behaves identically to the
29//! old single-ref free.
30//!
31//! ## Hash table (Phase 2 of block-level prefix cache)
32//!
33//! Each block can be tagged with a content-addressed `BlockHash` via
34//! [`BlockAllocator::register_block_hash`]. The hash is computed by
35//! chaining `parent_hash` with the block's tokens (see
36//! [`block_hash_chain`]) so identical prefixes across requests produce
37//! identical hash sequences.
38//!
39//! [`BlockAllocator::try_acquire_by_hash`] looks up a hash and bumps the
40//! ref count on hit. **Soft-free blocks** (ref=0 still in `free_list`
41//! with content intact) can be resurrected — this is what makes the cache
42//! actually useful across requests. `allocate` evicts a stale hash when
43//! a soft-free block is recycled with new content.
44//!
45//! ## What's *not* here yet (deferred to Phase 3-4 of prefix cache):
46//! - **Engine integration**: hashing incoming prompt blocks, looking up
47//! matching prefix, splicing the existing blocks into the new seq's
48//! block_table, running prefill only on the unmatched suffix.
49//! - **LRU eviction**: `free_list` is LIFO. Better policy would be
50//! LRU-soft-free so the oldest (least likely to hit) blocks are
51//! reused first. Today the most-recently-freed gets reused first
52//! (pessimistic for cross-request reuse).
53//! - **Preemption**: when blocks run out we still just `Err`; real
54//! scheduler would refuse-and-queue or evict.
55//! - Cross-process or cross-model pooling.
56
57use ferrum_kernels::backend::Backend;
58use ferrum_types::{FerrumError, Result, TokenId};
59use std::collections::HashMap;
60use std::hash::{Hash, Hasher};
61use std::sync::atomic::{AtomicUsize, Ordering};
62
63/// Hash identifying a content-addressable KV block. Computed from a chain
64/// of (parent_hash, block_tokens) — see [`block_hash_chain`].
65pub type BlockHash = u64;
66
67/// Compute the hash for one block, chained from its parent.
68///
69/// `parent` is the hash of the preceding block (0 for the first block).
70/// `tokens` are the `block_size` token ids in this block.
71///
72/// Uses Rust's default `SipHasher` for collision resistance with no extra
73/// deps. SipHash at ~1 GB/s processes a 16-token block (64 bytes) in tens
74/// of ns; per-request hashing of <200 blocks is well under a microsecond
75/// — invisible next to a prefill kernel.
76pub fn block_hash(parent: BlockHash, tokens: &[TokenId]) -> BlockHash {
77 let mut h = std::collections::hash_map::DefaultHasher::new();
78 parent.hash(&mut h);
79 for t in tokens {
80 t.get().hash(&mut h);
81 }
82 h.finish()
83}
84
85/// Compute the chained hash sequence for a token list, broken into
86/// `block_size`-token blocks. Trailing partial block (length <
87/// `block_size`) is dropped — partial blocks can't be looked up
88/// independently since their content depends on what gets appended next.
89pub fn block_hash_chain(tokens: &[TokenId], block_size: usize) -> Vec<BlockHash> {
90 let n = tokens.len() / block_size;
91 let mut out = Vec::with_capacity(n);
92 let mut parent: BlockHash = 0;
93 for chunk in tokens.chunks_exact(block_size) {
94 parent = block_hash(parent, chunk);
95 out.push(parent);
96 }
97 out
98}
99
100/// LIFO free-list block allocator. `O(1)` allocate / free, no fragmentation
101/// (all blocks are uniform size).
102///
103/// `capacity` is the total physical block count baked into the pool at
104/// load time. The allocator is independent per-model (block index space
105/// is not portable across models).
106pub struct BlockAllocator {
107 free_list: Vec<u32>,
108 capacity: u32,
109 /// Watermark: how many blocks have been live at peak, useful for
110 /// pool-sizing diagnostics in the bench harness.
111 peak_in_use: AtomicUsize,
112 /// Per-block reference count. `ref_counts[i] == 0` ⟺ block i is on
113 /// the free list (either freshly-uninitialized or soft-free with a
114 /// hash still registered). `allocate` sets to 1; `acquire` increments;
115 /// `free` decrements and (only when reaching 0) returns the block
116 /// to the free list. Width 16 bits — supports up to 65535 concurrent
117 /// sharers of the same physical KV block, far past any realistic
118 /// continuous batching cap.
119 ref_counts: Vec<u16>,
120 /// Block-hash lookup. Contains entries for every block whose content
121 /// has been hash-registered via [`Self::register_block_hash`] — both
122 /// live (ref ≥ 1) and soft-free (ref = 0 but content not yet
123 /// overwritten). [`Self::try_acquire_by_hash`] hits this map; on hit
124 /// the entry is resurrected with ref = 1 if it was soft-free.
125 /// Survives across requests — this is what makes the prefix cache
126 /// actually useful.
127 hash_table: HashMap<BlockHash, u32>,
128 /// Reverse map block_id → hash, for cleanup. When a block's content
129 /// gets overwritten by `allocate` returning it on a fresh request,
130 /// we use this to erase its stale hash from `hash_table`.
131 block_to_hash: Vec<Option<BlockHash>>,
132}
133
134impl BlockAllocator {
135 /// Create a fresh allocator. All `num_blocks` blocks start free.
136 /// Free-list is built so `allocate()` returns block 0 first, then 1,
137 /// etc. — predictable for tests and ensures the lower physical
138 /// blocks see the most reuse (better cache locality on M1's SLC).
139 pub fn new(num_blocks: u32) -> Self {
140 let mut free_list: Vec<u32> = (0..num_blocks).collect();
141 free_list.reverse(); // pop() yields 0 first
142 Self {
143 free_list,
144 capacity: num_blocks,
145 peak_in_use: AtomicUsize::new(0),
146 ref_counts: vec![0u16; num_blocks as usize],
147 hash_table: HashMap::new(),
148 block_to_hash: vec![None; num_blocks as usize],
149 }
150 }
151
152 /// Allocate a single physical block. Returns `Err` when the pool is
153 /// exhausted — caller is expected to refuse the request and queue
154 /// it (or evict another seq, when that's wired up).
155 ///
156 /// New block starts with `ref_count = 1`. Drop one ref via `free()`
157 /// to return it to the pool, or call `acquire()` for additional
158 /// sharers.
159 pub fn allocate(&mut self) -> Result<u32> {
160 match self.free_list.pop() {
161 Some(b) => {
162 debug_assert!(
163 self.ref_counts[b as usize] == 0,
164 "allocate yielded block {b} with non-zero ref_count {}",
165 self.ref_counts[b as usize]
166 );
167 // Soft-free block with a stale hash → content will be
168 // overwritten, so erase the hash mapping.
169 self.evict_hash_if_any(b);
170 self.ref_counts[b as usize] = 1;
171 let in_use = self.capacity as usize - self.free_list.len();
172 self.peak_in_use.fetch_max(in_use, Ordering::Relaxed);
173 Ok(b)
174 }
175 None => Err(FerrumError::resource_exhausted(format!(
176 "paged KV pool exhausted (capacity={} blocks, all in use)",
177 self.capacity
178 ))),
179 }
180 }
181
182 /// Internal: erase any hash mapping for a block whose content is
183 /// about to be overwritten. No-op if the block had no hash registered.
184 fn evict_hash_if_any(&mut self, block: u32) {
185 if let Some(h) = self.block_to_hash[block as usize].take() {
186 // Only erase from hash_table if it still points at THIS block.
187 // (Defensive — a hash collision later registering the same h
188 // to a different block could mean the entry now points
189 // elsewhere; we shouldn't yank the live one.)
190 if let Some(&mapped) = self.hash_table.get(&h) {
191 if mapped == block {
192 self.hash_table.remove(&h);
193 }
194 }
195 }
196 }
197
198 /// Bulk allocate. Atomic: either all `n` succeed or none are taken.
199 /// Each returned block starts at `ref_count = 1`.
200 pub fn allocate_n(&mut self, n: usize) -> Result<Vec<u32>> {
201 if self.free_list.len() < n {
202 return Err(FerrumError::resource_exhausted(format!(
203 "paged KV pool exhausted: need {n} blocks but only {} free",
204 self.free_list.len()
205 )));
206 }
207 let mut out = Vec::with_capacity(n);
208 for _ in 0..n {
209 let b = self.free_list.pop().unwrap();
210 debug_assert!(
211 self.ref_counts[b as usize] == 0,
212 "allocate_n yielded block {b} with non-zero ref_count"
213 );
214 self.evict_hash_if_any(b);
215 self.ref_counts[b as usize] = 1;
216 out.push(b);
217 }
218 let in_use = self.capacity as usize - self.free_list.len();
219 self.peak_in_use.fetch_max(in_use, Ordering::Relaxed);
220 Ok(out)
221 }
222
223 /// Look up a hash in the prefix-cache table. If the hash maps to a
224 /// physical block (live or soft-free), bump its ref count and return
225 /// the block id. A soft-free block (ref=0 in free_list) is
226 /// resurrected — pulled out of the free list and ref set to 1.
227 /// Returns `None` on cache miss.
228 pub fn try_acquire_by_hash(&mut self, hash: BlockHash) -> Option<u32> {
229 let &block = self.hash_table.get(&hash)?;
230 let bi = block as usize;
231 if self.ref_counts[bi] == 0 {
232 // Soft-free: pull out of free_list (O(n) on free_list length;
233 // typically small — few hundred at peak utilisation).
234 if let Some(pos) = self.free_list.iter().rposition(|&b| b == block) {
235 self.free_list.swap_remove(pos);
236 } else {
237 // Inconsistency: ref=0 but not in free_list. Shouldn't
238 // happen unless someone bypassed the API. Treat as miss
239 // to avoid corruption.
240 debug_assert!(false, "block {block} has ref=0 but not in free_list");
241 return None;
242 }
243 self.ref_counts[bi] = 1;
244 let in_use = self.capacity as usize - self.free_list.len();
245 self.peak_in_use.fetch_max(in_use, Ordering::Relaxed);
246 } else {
247 self.acquire(block);
248 }
249 Some(block)
250 }
251
252 /// Associate a hash with a block (after its content is written).
253 /// If the block already had a hash, the old mapping is replaced.
254 /// Block must currently be live (ref ≥ 1).
255 pub fn register_block_hash(&mut self, block: u32, hash: BlockHash) {
256 let bi = block as usize;
257 debug_assert!(
258 self.ref_counts[bi] > 0,
259 "register_block_hash on block {block} with ref_count=0 (not allocated)"
260 );
261 // Replace prior hash if any.
262 if let Some(old_h) = self.block_to_hash[bi].replace(hash) {
263 if old_h != hash {
264 if let Some(&mapped) = self.hash_table.get(&old_h) {
265 if mapped == block {
266 self.hash_table.remove(&old_h);
267 }
268 }
269 } else {
270 // Same hash being re-registered; nothing to clean up.
271 return;
272 }
273 }
274 // Last-writer-wins on collision (same hash different content —
275 // SipHash collisions on 64-token inputs are astronomically rare).
276 self.hash_table.insert(hash, block);
277 }
278
279 /// Read accessor for tests / debugging.
280 #[inline]
281 pub fn hash_table_size(&self) -> usize {
282 self.hash_table.len()
283 }
284
285 /// Increment the ref count for an already-allocated block. Used by
286 /// prefix-caching paths when a new sequence wants to share a block
287 /// that's already resident from a prior request.
288 ///
289 /// Panics in debug builds if the block is not currently allocated
290 /// (ref_count==0) — a release-build path that calls `acquire` on a
291 /// free block is a memory-safety bug we'd rather catch loudly.
292 pub fn acquire(&mut self, block: u32) {
293 let bi = block as usize;
294 debug_assert!(
295 self.ref_counts[bi] > 0,
296 "acquire on block {block} with ref_count=0 (not currently allocated)"
297 );
298 self.ref_counts[bi] = self.ref_counts[bi]
299 .checked_add(1)
300 .expect("BlockAllocator ref_count u16 overflow (>65535 sharers)");
301 }
302
303 /// Bulk acquire — convenience for prefix-cache hit paths that take a
304 /// list of physical block ids to share.
305 pub fn acquire_many(&mut self, blocks: &[u32]) {
306 for &b in blocks {
307 self.acquire(b);
308 }
309 }
310
311 /// Drop one ref from each block. Blocks whose ref_count hits 0 are
312 /// returned to the free list and become available for the next
313 /// `allocate*` call.
314 ///
315 /// Pre-prefix-cache callers see no behavioural change: every block
316 /// they hold starts at ref=1 (from `allocate`), and `free` drops it
317 /// to 0 — physically frees on the same call, same as before.
318 pub fn free(&mut self, blocks: &[u32]) {
319 for &b in blocks {
320 let bi = b as usize;
321 debug_assert!(
322 self.ref_counts[bi] > 0,
323 "free on block {b} with ref_count=0 (double-free)"
324 );
325 self.ref_counts[bi] -= 1;
326 if self.ref_counts[bi] == 0 {
327 self.free_list.push(b);
328 }
329 }
330 }
331
332 /// Read current ref count for a block. 0 means free, ≥1 means in
333 /// use by that many sequences.
334 #[inline]
335 pub fn ref_count(&self, block: u32) -> u16 {
336 self.ref_counts[block as usize]
337 }
338
339 pub fn free_count(&self) -> usize {
340 self.free_list.len()
341 }
342
343 pub fn capacity(&self) -> u32 {
344 self.capacity
345 }
346
347 pub fn peak_in_use(&self) -> usize {
348 self.peak_in_use.load(Ordering::Relaxed)
349 }
350}
351
352/// Per-sequence paged-KV state.
353///
354/// Holds the logical→physical block mapping for ONE sequence (one
355/// `cache_id`) plus its current token count. The mapping is stored as
356/// both:
357/// - `blocks: Vec<u32>` — the host-side source of truth, used by the
358/// block allocator + grow logic.
359/// - `block_table_buf: B::Buffer` — a device-side u32 buffer that mirrors
360/// `blocks` and is read directly by the paged Metal kernels (PR #68 /
361/// #69). Kept in sync via [`Self::ensure_capacity`].
362///
363/// `context_lens_buf` is a 1-element u32 device buffer holding `len`.
364/// The kernel reads it each forward; we update it via `B::write_u32`.
365pub struct PagedSeqState<B: Backend> {
366 pub blocks: Vec<u32>,
367 pub block_table_buf: B::Buffer,
368 pub context_lens_buf: B::Buffer,
369 pub len: usize,
370 pub block_size: usize,
371 pub max_blocks_per_seq: usize,
372}
373
374impl<B: Backend> PagedSeqState<B> {
375 /// Allocate buffers for a sequence that hasn't yet allocated any
376 /// blocks. The allocator isn't touched here — the first call to
377 /// [`Self::ensure_capacity`] does the real work.
378 pub fn new(block_size: usize, max_blocks_per_seq: usize) -> Self {
379 let block_table_buf =
380 B::alloc_typed(ferrum_kernels::backend::Dtype::U32, max_blocks_per_seq);
381 let context_lens_buf = B::alloc_typed(ferrum_kernels::backend::Dtype::U32, 1);
382 // Initialise context_lens to 0 so a forward dispatched before
383 // any token has been written sees an empty context.
384 let mut ctx = B::new_context();
385 let mut cl = context_lens_buf;
386 B::write_typed::<u32>(&mut ctx, &mut cl, &[0u32]);
387 B::sync(&mut ctx);
388 Self {
389 blocks: Vec::with_capacity(max_blocks_per_seq),
390 block_table_buf,
391 context_lens_buf: cl,
392 len: 0,
393 block_size,
394 max_blocks_per_seq,
395 }
396 }
397
398 /// Ensure the seq has enough blocks to hold `target_len` tokens.
399 /// Allocates additional blocks from the pool if needed and re-syncs
400 /// `block_table_buf` to the device. Idempotent if already big enough.
401 pub fn ensure_capacity(
402 &mut self,
403 ctx: &mut B::Context,
404 alloc: &mut BlockAllocator,
405 target_len: usize,
406 ) -> Result<()> {
407 let needed = target_len.div_ceil(self.block_size);
408 if needed > self.max_blocks_per_seq {
409 return Err(FerrumError::model(format!(
410 "paged KV: target_len={target_len} would need {needed} blocks, exceeds max_blocks_per_seq={}",
411 self.max_blocks_per_seq
412 )));
413 }
414 while self.blocks.len() < needed {
415 let block = alloc.allocate()?;
416 self.blocks.push(block);
417 }
418 // Mirror the host-side blocks list into the device buffer. We
419 // write the FULL `max_blocks_per_seq` entries — unused slots
420 // beyond `needed` are never read by the kernel (it only walks
421 // `[0, ceil(context_len / block_size))`), but writing them
422 // keeps the buffer's content predictable.
423 let mut padded = self.blocks.clone();
424 padded.resize(self.max_blocks_per_seq, 0);
425 B::write_typed::<u32>(ctx, &mut self.block_table_buf, &padded);
426 Ok(())
427 }
428
429 /// Update the on-device `context_lens_buf` to the current `self.len`.
430 /// Call this after [`Self::ensure_capacity`] but before dispatching
431 /// the paged attention kernel for this seq.
432 pub fn sync_context_len(&mut self, ctx: &mut B::Context) {
433 B::write_typed::<u32>(ctx, &mut self.context_lens_buf, &[self.len as u32]);
434 }
435
436 /// Release all blocks back to the allocator. Buffers are kept (cheap
437 /// to reuse for a future cache_id), but blocks become available for
438 /// other sequences. Sets `len` back to 0.
439 pub fn release(&mut self, alloc: &mut BlockAllocator) {
440 alloc.free(&self.blocks);
441 self.blocks.clear();
442 self.len = 0;
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn allocator_basic() {
452 let mut a = BlockAllocator::new(4);
453 assert_eq!(a.free_count(), 4);
454 assert_eq!(a.allocate().unwrap(), 0);
455 assert_eq!(a.allocate().unwrap(), 1);
456 assert_eq!(a.allocate().unwrap(), 2);
457 assert_eq!(a.allocate().unwrap(), 3);
458 assert!(a.allocate().is_err());
459 assert_eq!(a.free_count(), 0);
460
461 a.free(&[1, 3]);
462 assert_eq!(a.free_count(), 2);
463 // LIFO: most recently freed comes back first.
464 assert_eq!(a.allocate().unwrap(), 3);
465 assert_eq!(a.allocate().unwrap(), 1);
466 }
467
468 #[test]
469 fn allocator_atomic_n_failure() {
470 let mut a = BlockAllocator::new(3);
471 let _ = a.allocate().unwrap(); // 1 left in free_list... wait, 2 left
472 let _ = a.allocate().unwrap();
473 // 1 free, asking for 2 should fail without consuming the 1.
474 assert!(a.allocate_n(2).is_err());
475 assert_eq!(a.free_count(), 1);
476 }
477
478 #[test]
479 fn allocator_peak_tracking() {
480 let mut a = BlockAllocator::new(8);
481 let blocks = a.allocate_n(5).unwrap();
482 assert_eq!(a.peak_in_use(), 5);
483 a.free(&blocks);
484 assert_eq!(a.peak_in_use(), 5); // peak doesn't decrease
485 let _ = a.allocate_n(3).unwrap();
486 assert_eq!(a.peak_in_use(), 5);
487 }
488
489 #[test]
490 fn refcount_allocate_starts_at_one() {
491 let mut a = BlockAllocator::new(4);
492 let b = a.allocate().unwrap();
493 assert_eq!(a.ref_count(b), 1);
494 }
495
496 #[test]
497 fn refcount_acquire_increments() {
498 let mut a = BlockAllocator::new(4);
499 let b = a.allocate().unwrap();
500 a.acquire(b);
501 a.acquire(b);
502 assert_eq!(a.ref_count(b), 3);
503 }
504
505 #[test]
506 fn refcount_free_decrements_no_physical_release() {
507 let mut a = BlockAllocator::new(4);
508 let b = a.allocate().unwrap();
509 a.acquire(b); // ref=2
510 a.free(&[b]); // ref=1 — NOT yet returned to free_list
511 assert_eq!(a.ref_count(b), 1);
512 assert_eq!(a.free_count(), 3); // only 3 of original 4 blocks free
513 }
514
515 #[test]
516 fn refcount_free_physical_release_at_zero() {
517 let mut a = BlockAllocator::new(4);
518 let b = a.allocate().unwrap();
519 a.acquire(b); // ref=2
520 a.free(&[b]); // ref=1
521 a.free(&[b]); // ref=0 → physical release
522 assert_eq!(a.ref_count(b), 0);
523 assert_eq!(a.free_count(), 4);
524 }
525
526 #[test]
527 fn refcount_legacy_single_ref_behaviour_unchanged() {
528 // Pre-prefix-cache code never calls `acquire`. Verify that
529 // allocate+free behaves identically to the old version: block
530 // immediately returns to the pool.
531 let mut a = BlockAllocator::new(2);
532 let b = a.allocate().unwrap();
533 assert_eq!(a.free_count(), 1);
534 a.free(&[b]);
535 assert_eq!(a.free_count(), 2);
536 assert_eq!(a.ref_count(b), 0);
537 // Re-allocation reuses the slot (LIFO).
538 let b2 = a.allocate().unwrap();
539 assert_eq!(b2, b);
540 }
541
542 #[test]
543 fn refcount_bulk_acquire_and_release() {
544 let mut a = BlockAllocator::new(8);
545 let blocks = a.allocate_n(3).unwrap();
546 a.acquire_many(&blocks); // each ref=2
547 for &b in &blocks {
548 assert_eq!(a.ref_count(b), 2);
549 }
550 a.free(&blocks); // each ref=1, none physically released
551 assert_eq!(a.free_count(), 5);
552 a.free(&blocks); // each ref=0, all released
553 assert_eq!(a.free_count(), 8);
554 }
555
556 // ── Phase 2: hash table tests ────────────────────────────────────────
557
558 fn toks(ids: &[u32]) -> Vec<TokenId> {
559 ids.iter().map(|&i| TokenId::new(i)).collect()
560 }
561
562 #[test]
563 fn block_hash_chain_basic() {
564 let tokens = toks(&[1, 2, 3, 4, 5, 6, 7, 8]);
565 let chain = block_hash_chain(&tokens, 4);
566 assert_eq!(chain.len(), 2);
567 // Identical second block must produce a DIFFERENT chained hash
568 // because its parent (first block) is the same here but the
569 // chain order forces hash[1] = h(hash[0], tokens[4..8]).
570 assert_ne!(chain[0], chain[1]);
571 }
572
573 #[test]
574 fn block_hash_chain_identical_prefix_same_hashes() {
575 let a = toks(&[10, 20, 30, 40, 50, 60, 70, 80]);
576 let b = toks(&[10, 20, 30, 40, 99, 99, 99, 99]);
577 let ca = block_hash_chain(&a, 4);
578 let cb = block_hash_chain(&b, 4);
579 assert_eq!(ca[0], cb[0], "first block matches → same hash[0]");
580 assert_ne!(ca[1], cb[1], "second block differs → different hash[1]");
581 }
582
583 #[test]
584 fn block_hash_chain_drops_partial_trailing() {
585 let tokens = toks(&[1, 2, 3, 4, 5, 6, 7]); // 7 tokens, block_size=4 → 1 full
586 let chain = block_hash_chain(&tokens, 4);
587 assert_eq!(chain.len(), 1);
588 }
589
590 #[test]
591 fn hash_table_register_and_acquire_live() {
592 let mut a = BlockAllocator::new(4);
593 let b = a.allocate().unwrap();
594 let h = 0xDEAD_BEEFu64;
595 a.register_block_hash(b, h);
596 assert_eq!(a.hash_table_size(), 1);
597 // Re-acquire by hash: must bump ref (block is live)
598 let got = a.try_acquire_by_hash(h);
599 assert_eq!(got, Some(b));
600 assert_eq!(a.ref_count(b), 2);
601 }
602
603 #[test]
604 fn hash_table_soft_free_resurrection() {
605 let mut a = BlockAllocator::new(4);
606 let b = a.allocate().unwrap();
607 let h = 0xABCD_1234u64;
608 a.register_block_hash(b, h);
609 a.free(&[b]); // ref=0, block in free_list, hash STILL registered
610 assert_eq!(a.free_count(), 4);
611 assert_eq!(a.ref_count(b), 0);
612 // Cache hit on the soft-free block: must resurrect with ref=1
613 let got = a.try_acquire_by_hash(h);
614 assert_eq!(got, Some(b));
615 assert_eq!(a.ref_count(b), 1);
616 // The block is OFF the free_list now.
617 assert_eq!(a.free_count(), 3);
618 }
619
620 #[test]
621 fn hash_table_miss_returns_none() {
622 let mut a = BlockAllocator::new(4);
623 let b = a.allocate().unwrap();
624 a.register_block_hash(b, 1);
625 assert_eq!(a.try_acquire_by_hash(99), None);
626 // No side effects on miss
627 assert_eq!(a.ref_count(b), 1);
628 }
629
630 #[test]
631 fn hash_table_evicts_on_realloc() {
632 // Allocate, register hash, free → soft-free with hash. Then
633 // allocate a new block — the recycled one's hash must be erased
634 // because its content is about to be overwritten.
635 let mut a = BlockAllocator::new(2);
636 let b1 = a.allocate().unwrap();
637 let h = 0x1234u64;
638 a.register_block_hash(b1, h);
639 a.free(&[b1]); // soft-free
640 assert_eq!(a.hash_table_size(), 1);
641 // Allocate to consume the only free block — should recycle b1
642 // and evict its hash.
643 let b2 = a.allocate().unwrap();
644 assert_eq!(b2, b1, "LIFO should reuse b1");
645 assert_eq!(a.hash_table_size(), 0, "stale hash erased on realloc");
646 assert_eq!(a.try_acquire_by_hash(h), None);
647 }
648
649 #[test]
650 fn hash_table_replace_block_hash() {
651 let mut a = BlockAllocator::new(4);
652 let b = a.allocate().unwrap();
653 a.register_block_hash(b, 1);
654 a.register_block_hash(b, 2); // overwrite
655 assert_eq!(a.hash_table_size(), 1);
656 assert_eq!(a.try_acquire_by_hash(1), None);
657 assert_eq!(a.try_acquire_by_hash(2), Some(b));
658 }
659}