read: validate the store in 96 bytes, and hold the layout with the table

The control block is 4096 bytes, and the question every read asks of it is one number — has
this store changed since I last looked. That number, and the fields beside it, are in the
first `CTL_SUMMED + 4` bytes of each copy. So a call that finds the store unmoved now reads
two heads and nothing else; the header, the space table, the layout and the log's extent all
come from the cache. The full 4096-byte read happens only when the store has actually moved.

Both heads are read, and that is the whole safety of it: a write raises one copy's generation
and leaves the other at the old one, so reading a single copy and finding it unchanged would
call a moved store unmoved. The pair is what makes the answer true.

Measured on #84, in the guest, against #83:
    get   0.077 -> 0.038 ms in the 20,000-record store  (and 0.052 in the 500-record one)
    miss  0.074 -> 0.044 ms
    spaces 0.374 -> 0.173 ms
    worst case: read 0.763 ms (ceiling 5), write 6.762 ms (ceiling 50)
A read is now about twice as fast, and it is finally faster in the LARGER store than in the
small one — which the flatness before could not show. That is the diagnosis paying off: the
per-call cost of a read was setup rather than device reads all along, and the largest single
piece of that setup was reading 4 KB to compare 8 bytes.

Also derives `Copy` for `HeaderV3` (six plain numbers; a cached header has to be handed out by
value) and drops the search buffer `space_entry` no longer needs, now that the table it
searches is in memory.
This commit is contained in:
surface-camera-build
2026-09-23 18:37:23 -04:00
parent 1f7b9fe0de
commit b8a7f615ff
+104 -45
View File
@@ -405,7 +405,18 @@ kernel::sync::global_lock! {
struct TableCache {
generation: u64,
image_off: u64,
/// The v3/v4 header, so a call that finds the store unmoved does not read it again.
header: HeaderV3,
/// Where the log is and how much of it is in use — from the same control block the generation
/// came from, so a cache hit needs no second read to find the log.
log_off: u64,
log_used: u64,
table: KVVec<u8>,
/// The two control-block heads, and a scratch to read them with: both persist so that validating
/// a call costs no allocation. A 4096-byte scratch here is what the whole block used to need;
/// 96 bytes of head is all the validation ever wanted.
heads: KVVec<u8>,
scratch: KVVec<u8>,
}
kernel::sync::global_lock! {
@@ -528,6 +539,11 @@ fn parse_header(image: &[u8]) -> Result<Header, &'static str> {
}
/// The v3 header: what a reader needs to address the image by arithmetic.
///
/// `Copy` because it is six plain numbers and every reader of it wants a value, not a borrow — a
/// cached header has to be handed out by value, and nothing here is big enough to be worth
/// borrowing.
#[derive(Clone, Copy)]
struct HeaderV3 {
/// `VERSION_V3` or `VERSION_V4` — they share the geometry and differ only in the index
/// entry's stride.
@@ -2905,20 +2921,65 @@ struct Addressed {
}
impl Addressed {
/// Open the store and read its control block, v3 header, and log window — not its records.
/// Open the store: validate it in 96 bytes, and read the rest only when it has moved.
///
/// The control block is 4096 bytes and the question every call asks of it is one number — has
/// this store changed since I last looked. That number, and the fields beside it, are in the
/// first `CTL_SUMMED + 4` bytes of each copy, so a call that finds the store unmoved reads two
/// heads and nothing else: the header, the space table and the layout come from the cache.
/// Measured, the per-call cost of a read is setup rather than device reads, and this is the
/// largest single piece of that setup.
///
/// **Both heads are read**, and that is the whole safety of it: a write raises one copy's
/// generation and leaves the other at the old one, so reading only one copy and finding it
/// unchanged would call a moved store unmoved. The pair is what makes the answer true.
fn open() -> Result<Option<Self>> {
let file = store_file()?;
let mut guard = TABLE_CACHE.lock();
if guard.is_none() {
*guard = Some(TableCache {
generation: u64::MAX,
image_off: 0,
header: HeaderV3 {
version: VERSION_V3,
image_bytes: 0,
record_count: 0,
space_count: 0,
index_off: 0,
values_off: 0,
},
log_off: 0,
log_used: 0,
table: KVVec::new(),
heads: KVVec::new(),
scratch: KVVec::new(),
});
}
let cache = guard.as_mut().unwrap();
if cache.scratch.len() == 0 {
cache.scratch = KVVec::<u8>::with_capacity(4096, GFP_KERNEL)?;
cache.scratch.resize(4096, 0, GFP_KERNEL)?;
}
let head_len = CTL_SUMMED + 4;
read_exact_at(file, CTL_COPY_A as u64, head_len, &mut cache.heads, &mut cache.scratch)?;
let gen_a = Control::decode(cache.heads.as_slice()).map(|c| c.generation);
read_exact_at(file, CTL_COPY_B as u64, head_len, &mut cache.heads, &mut cache.scratch)?;
let gen_b = Control::decode(cache.heads.as_slice()).map(|c| c.generation);
let generation = match (gen_a, gen_b) {
(Some(x), Some(y)) => core::cmp::max(x, y),
(Some(x), None) => x,
(None, Some(y)) => y,
// Neither copy decodes: there is no control block, so this is a bare image and the
// walking reader handles it.
(None, None) => return Ok(None),
} as u64;
let mut opened = Addressed {
file,
image_off: 0,
header: HeaderV3 {
version: VERSION_V3,
image_bytes: 0,
record_count: 0,
space_count: 0,
index_off: 0,
values_off: 0,
},
image_off: cache.image_off,
header: cache.header,
log: KVVec::new(),
spaces: KVVec::new(),
scratch: KVVec::new(),
@@ -2926,15 +2987,25 @@ impl Addressed {
opened.scratch = KVVec::<u8>::with_capacity(4096, GFP_KERNEL)?;
opened.scratch.resize(4096, 0, GFP_KERNEL)?;
let mut head = KVVec::<u8>::with_capacity(CTL_COPY_B + CTL_COPY_LEN, GFP_KERNEL)?;
read_exact_at(file, 0, CTL_COPY_B + CTL_COPY_LEN, &mut head, &mut opened.scratch)?;
if &head.as_slice()[0..4] != CTL_MAGIC.as_slice() {
return Ok(None); // a bare image, which the walking reader handles
if cache.generation == generation {
// The store has not moved: what describes it is what we already hold. The log is still
// read, because it is the one thing that changes without a fold — and a write raises the
// generation, so a log newer than this cache is a log this branch does not see.
opened.spaces.extend_from_slice(cache.table.as_slice(), GFP_KERNEL)?;
if cache.log_used > 0 {
let len = core::cmp::min(WAL_HEADER_LEN + cache.log_used as usize, MAX_BYTES);
read_exact_at(file, cache.log_off, len, &mut opened.log, &mut opened.scratch)?;
}
return Ok(Some(opened));
}
// The store moved, or this is the first call: read what describes it, once, and keep it.
let mut head = KVVec::<u8>::new();
read_exact_at(file, 0, CTL_COPY_B + CTL_COPY_LEN, &mut head, &mut opened.scratch)?;
let control = read_control(head.as_slice()).ok_or(EINVAL)?;
opened.image_off = control.image_off() as u64;
let mut raw = KVVec::<u8>::with_capacity(HEADER_LEN_V3, GFP_KERNEL)?;
let mut raw = KVVec::<u8>::new();
read_exact_at(file, opened.image_off, HEADER_LEN_V3, &mut raw, &mut opened.scratch)?;
if raw.as_slice()[4] != VERSION_V3 && raw.as_slice()[4] != VERSION_V4 {
return Ok(None); // v1 or v2: the walking reader
@@ -2944,41 +3015,31 @@ impl Addressed {
EINVAL
})?;
// The space table: the cache's copy when the store has not moved, a fresh read when it has.
let bytes = opened.header.space_count as usize * SPACE_ENTRY;
if bytes > MAX_BYTES {
return Err(EINVAL);
}
{
let mut guard = TABLE_CACHE.lock();
if guard.is_none() {
*guard = Some(TableCache {
generation: u64::MAX,
image_off: 0,
table: KVVec::new(),
});
}
let cache = guard.as_mut().unwrap();
if cache.generation != control.generation || cache.image_off != opened.image_off {
read_exact_at(
file,
opened.image_off + HEADER_LEN_V3 as u64,
bytes,
&mut cache.table,
&mut opened.scratch,
)?;
cache.generation = control.generation;
cache.image_off = opened.image_off;
}
// Copied rather than borrowed: the table is `space_count * 48` bytes — a few hundred here,
// and the copy is cheaper than the reads it replaces.
opened.spaces.extend_from_slice(cache.table.as_slice(), GFP_KERNEL)?;
}
read_exact_at(
file,
opened.image_off + HEADER_LEN_V3 as u64,
bytes,
&mut cache.table,
&mut opened.scratch,
)?;
// Copied rather than borrowed: the table is `space_count * 48` bytes — a few hundred here,
// and the copy is cheaper than the reads it replaces.
opened.spaces.extend_from_slice(cache.table.as_slice(), GFP_KERNEL)?;
if control.log_used > 0 {
let len = core::cmp::min(WAL_HEADER_LEN + control.log_used as usize, MAX_BYTES);
read_exact_at(file, control.log_off() as u64, len, &mut opened.log, &mut opened.scratch)?;
}
cache.generation = generation;
cache.image_off = opened.image_off;
cache.header = opened.header;
cache.log_off = control.log_off();
cache.log_used = control.log_used;
Ok(Some(opened))
}
@@ -2995,10 +3056,8 @@ impl Addressed {
/// The table is sorted by space and fixed-size, so this is a binary search over arithmetic
/// addresses — the same reason the index works.
fn space_entry(&mut self, space: &[u8; SPACE_ID_LEN]) -> Result<Option<(u64, u64)>> {
// One buffer for the whole search, not one per probe: a binary search over a thousand
// spaces allocates a thousand times otherwise, and an allocator stall is a latency spike
// with no bound on it — the opposite of what a control loop is allowed to see.
let mut buf = KVVec::<u8>::new();
// No buffer and no read: the table is held across calls, so this search is arithmetic over
// memory. It used to allocate once per search and read the device per probe.
let mut lo = 0u64;
let mut hi = self.header.space_count;
while lo < hi {