From 07da383e1c1719b6c26f8da10d5055ba525168d9 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 18:00:11 -0700 Subject: [PATCH 1/8] Remove planned entry --- PLANNED.md | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/PLANNED.md b/PLANNED.md index 88b853c..a3e49c9 100644 --- a/PLANNED.md +++ b/PLANNED.md @@ -130,40 +130,3 @@ The mutex's scope and implementation are not in question here (see the `NOT PLAN ### Open questions - **Validation.** Whether `mixed/uniform` workload is sufficient to measure improvement, or whether a contention-specific microbenchmark (concurrent non-tail alloc/dealloc only) is needed to isolate the critical-section-size effect. - -## Chunked slice view, sorting, search, and selection - -**Feature flag:** `set` (mutating chunk operations: sort/select) or no flag (read-only chunking, search); chunk movement additionally requires `atomic`, per the `BStackSlice::swap`/`rotate_*`/`copy_within` primitives proposed above. -**Breaking change:** No — purely additive; introduces new types rather than modifying existing ones. - -### Motivation - -Data stored in a `BStackSlice`/`BStackByteVec` is rarely a flat stream meant to be sorted byte-by-byte — it's much more commonly fixed-width records (keys, struct-like entries) packed contiguously, where the caller wants to reorder whole records while leaving each record's internal bytes untouched. Byte-level `sort` is therefore not the useful primitive; the useful primitive is `std`'s `chunks_exact` pattern — dividing a slice into fixed-size units — with sorting, searching, and selection defined over those units. This was flagged as an open question in "Additional slice and vector APIs" and is developed here as its own feature, since it needs a new type rather than a method added to `BStackSlice` directly. - -### Design - -- **`BStackSlice::chunks_exact(&self, chunk_len: u64) -> BStackChunks<'a>`** — divides the slice into `chunk_len`-byte records, mirroring `[T]::chunks_exact`. Only the exact form is offered — a variable-length final chunk (`[T]::chunks`) has no natural place in a record-oriented view, since a short trailing chunk isn't a record of the type being sorted/searched/selected. A short remainder is exposed via `.remainder() -> BStackSlice<'a>`, matching `[T]::chunks_exact`; iterating `BStackChunks` yields `BStackSlice` sub-slices. Read-only construction and iteration need no feature flag. - -- **Sorting**, gated on `set` (+ `atomic` for movement): `BStackChunks::sort_by(&mut self, cmp: impl FnMut(&[u8], &[u8]) -> Ordering)` and `sort_by_key(&mut self, key: impl FnMut(&[u8]) -> K) where K: Ord`. No comparator-free `sort()` on raw chunk bytes is proposed — sorting undifferentiated byte records by their own byte order is rarely the caller's intent. Movement is always whole-chunk (via `swap`/`copy_within`-style primitives), so a record's bytes are moved as a unit and never split. - - Internally, the implementation picks a strategy by data size — the caller sees only `sort_by`/`sort_by_key`: - - **Small** (chunk data fits comfortably in memory): read every chunk into an in-memory buffer, sort there with ordinary in-memory sorting, then write the result back. - - **Medium**: split into sections that individually fit in memory, sort each section in place, then merge adjacent sorted sections. - - **Large**: classic external-merge-sort — same sort-section-then-merge idea as medium, generalized to multiple sections and multiple merge passes (k-way or repeated pairwise merge) so no single pass needs more than one section resident at once. - - The medium/large split is the same algorithm at different scale (external sort is section-sort-then-merge applied recursively); "small" is just the degenerate one-section case. Thresholds are an implementation detail, not part of the public contract. - - **Whole-sort atomicity.** `sort_by`/`sort_by_key` commit as a single crash-atomic transaction (journaled multi-write, extending the `inplace_gen` machinery to a batch computed ahead of time rather than issued live): a crash during sort leaves either the pre-sort order or the fully-sorted order, never an intermediate permutation. This is necessary because the small/medium/large strategies above write back in a different shape than a simple swap sequence (e.g. a full rewrite for "small"), so per-swap atomicity alone wouldn't give a caller a stable story across strategies. - - **`sort_partial_by`/`sort_partial_by_key`** — a variant that is explicitly allowed to stop early and leave the data only partially sorted (e.g. after some bounded number of merge passes or sections, for a large slice where a full sort is prohibitively expensive but an approximately-sorted result is acceptable). "Partial" only ever means *not fully ordered*, never corrupted or lost data: every completed step (section sort, merge pass) is itself committed atomically, so the visible state after a partial sort is always some valid permutation of the original records. - -- **Binary search.** `BStackChunks::binary_search_by(&self, cmp: impl FnMut(&[u8]) -> Ordering) -> Result` and `binary_search_by_key` — standard binary search over chunks, requiring the chunks already be ordered by the same key/comparator (caller's responsibility, as in `std`). Read-only, no feature flag. - -- **Quickselect.** `BStackChunks::select_nth_by(&mut self, n: u64, cmp: impl FnMut(&[u8], &[u8]) -> Ordering)` / `select_nth_by_key`, mirroring `[T]::select_nth_unstable_by(_key)`: partitions chunks so the `n`th is in its sorted position, with unspecified order on either side. Gated on `set` (+ `atomic`), same whole-operation atomicity guarantee as `sort_by` — a crash leaves either the original order or a valid completed partition, never a half-applied one. - -### Open questions - -- **Unstable variants.** Whether to also provide `sort_unstable_by`/`sort_unstable_by_key`, mirroring `std`'s stable/unstable split, given unstable sort does fewer chunk moves at the cost of stability. -- **Section/threshold sizing.** How the small/medium/large boundary and section size are chosen — fixed constant, fraction of available memory, or caller-configurable — and how that interacts with `select_nth_by`'s partitioning for very large slices (quickselect also needs random access across the full range, so it may need its own out-of-core strategy rather than assuming in-memory partitioning). -- **`chunks_mut`.** Whether mutable, non-sort chunk iteration (for per-chunk in-place editing without a full sort) is worth exposing alongside the sort/select-specific API. -- **Naming.** `BStackChunks` is a working name. From 175e72071345a79b44903f3c372906e8519f4cd4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 20:21:17 -0700 Subject: [PATCH 2/8] Add chunking --- src/alloc/chunk.rs | 569 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 569 insertions(+) create mode 100644 src/alloc/chunk.rs diff --git a/src/alloc/chunk.rs b/src/alloc/chunk.rs new file mode 100644 index 0000000..6ce99ff --- /dev/null +++ b/src/alloc/chunk.rs @@ -0,0 +1,569 @@ +//! Fixed-stride chunked view over a [`BStackSlice`]. +//! +//! Requires feature `alloc`. Sorting and selection additionally require +//! `set` + `atomic`; construction, iteration, and binary search need no flag +//! beyond `alloc`. + +use super::{BStackAllocator, BStackOwnedSlice, BStackSlice}; +use crate::BStack; +use std::cmp::Ordering; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::io; + +/// A fixed-size-record view over a [`BStackSlice`] — a slice with a stride. +/// +/// Divides an underlying region into `chunk_len`-byte records. `BStackChunk` +/// sits at the same semantic position as [`BStackSlice`]: it carries `&'a +/// BStack` directly, performs I/O only through explicit methods, and has no +/// allocator operations of its own. +/// +/// `BStackChunk` is a **view**, not an iterator — it does not implement +/// [`Iterator`]. Call [`iter`](Self::iter) (or use `IntoIterator`) to walk +/// its chunks lazily, or call [`sort_by`](Self::sort_by), +/// [`binary_search_by`](Self::binary_search_by), or +/// [`select_nth_by`](Self::select_nth_by) directly on the view. +/// +/// `BStackChunk` is always fully aligned — it covers only the whole-chunk +/// portion of its source. Obtained via [`BStackSlice::chunks`] or +/// [`BStackSlice::rchunks`], which each return `(BStackChunk, BStackSlice)`: +/// the aligned view alongside whatever leftover bytes (if `chunk_len` does +/// not evenly divide the source length) don't fit a whole chunk. The two +/// constructors differ only in which end of the source they align from, and +/// therefore which sub-region ends up in the chunk view versus the leftover +/// slice. +/// +/// # Not `Copy` +/// +/// Like `BStackSlice`, deliberately non-`Copy`: `&mut self` methods +/// ([`sort_by`](Self::sort_by), [`select_nth_by`](Self::select_nth_by), …) +/// give genuine single-writer exclusivity in safe code. `Clone` for cases +/// where an explicit second view is needed. +pub struct BStackChunk<'a> { + aligned: BStackSlice<'a>, + chunk_len: u64, +} + +impl<'a> Clone for BStackChunk<'a> { + #[inline] + fn clone(&self) -> Self { + BStackChunk { + aligned: self.aligned.clone(), + chunk_len: self.chunk_len, + } + } +} + +impl<'a> fmt::Debug for BStackChunk<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BStackChunk") + .field("chunk_len", &self.chunk_len) + .field("chunk_count", &self.chunk_count()) + .finish_non_exhaustive() + } +} + +/// Location equality: same stride *and* the same underlying region +/// (`self.as_slice() == other.as_slice()`), not content. No cross-type +/// `PartialEq` against a bare [`BStackSlice`] is provided — a `BStackChunk` +/// with stride 4 and one with stride 8 over the identical bytes are not +/// interchangeable, so comparing a chunk view directly to a plain slice +/// would silently discard the stride and invite confusion. +impl<'a> PartialEq for BStackChunk<'a> { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.chunk_len == other.chunk_len && self.aligned == other.aligned + } +} + +impl<'a> Eq for BStackChunk<'a> {} + +impl<'a> Hash for BStackChunk<'a> { + #[inline] + fn hash(&self, state: &mut H) { + self.chunk_len.hash(state); + self.aligned.hash(state); + } +} + +/// Ordered first by stride, then by the underlying region's own `Ord` +/// (`offset`, then `len`). +impl<'a> PartialOrd for BStackChunk<'a> { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl<'a> Ord for BStackChunk<'a> { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + self.chunk_len + .cmp(&other.chunk_len) + .then_with(|| self.aligned.cmp(&other.aligned)) + } +} + +impl<'a> BStackChunk<'a> { + /// Length, in bytes, of one chunk. + #[inline] + pub fn chunk_len(&self) -> u64 { + self.chunk_len + } + + /// Number of complete chunks in the view. + #[inline] + pub fn chunk_count(&self) -> u64 { + self.aligned.len() / self.chunk_len + } + + /// Total bytes covered by complete chunks. + #[inline] + pub fn len(&self) -> u64 { + self.aligned.len() + } + + /// Returns `true` if there are no complete chunks. + #[inline] + pub fn is_empty(&self) -> bool { + self.aligned.is_empty() + } + + /// The aligned region covered by whole chunks, as a plain [`BStackSlice`]. + #[inline] + pub fn as_slice(&self) -> BStackSlice<'a> { + self.aligned.clone() + } + + /// Consume this view, returning the aligned region as a plain + /// [`BStackSlice`] without cloning. + #[inline] + pub fn into_slice(self) -> BStackSlice<'a> { + self.aligned + } + + /// Re-divide this view's aligned region with a different stride, + /// returning `(chunk_view, remainder)` — equivalent to + /// `self.into_slice().chunks(new_stride)`. + /// + /// # Panics + /// + /// Panics if `new_stride == 0`. + #[inline] + pub fn with_stride(self, new_stride: u64) -> (BStackChunk<'a>, BStackSlice<'a>) { + self.aligned.chunks(new_stride) + } + + /// Return the underlying [`BStack`]. + #[inline] + pub fn stack(&self) -> &'a BStack { + self.aligned.stack() + } + + /// Get the `index`-th chunk, or `None` if out of bounds. + /// + /// O(1), pure offset arithmetic — no I/O. + #[inline] + pub fn get(&self, index: u64) -> Option> { + if index >= self.chunk_count() { + return None; + } + let start = index * self.chunk_len; + Some(self.aligned.subslice(start, start + self.chunk_len)) + } + + /// Create a lazy iterator over the chunks of this view. + /// + /// This clones the view; the iterator and `self` are independent. See + /// [`BStackChunkIter`] for the laziness guarantee. + #[inline] + pub fn iter(&self) -> BStackChunkIter<'a> { + BStackChunkIter { + remaining: self.aligned.clone(), + chunk_len: self.chunk_len, + } + } + + /// Sort chunks in place by `cmp`, comparing each chunk's raw bytes. + /// + /// Crash-atomic as a single transaction: a single [`BStack::process`] + /// call reads the whole chunked region (excluding the remainder) into + /// memory, sorts there, and commits the result in one write — a crash + /// leaves either the pre-sort order or the fully-sorted order, never an + /// intermediate permutation. Stable: chunks that compare equal keep + /// their relative order, matching `[T]::sort_by`. + /// + /// Requires the `set` and `atomic` features. + #[cfg(all(feature = "set", feature = "atomic"))] + pub fn sort_by(&mut self, mut cmp: impl FnMut(&[u8], &[u8]) -> Ordering) -> io::Result<()> { + let chunk_len = self.chunk_len as usize; + let start = self.aligned.start(); + let end = self.aligned.end(); + self.aligned.stack().process(start, end, |buf| { + sort_chunks_by(buf, chunk_len, &mut cmp); + }) + } + + /// Sort chunks in place by a key extracted from each chunk's bytes. + /// + /// The key is computed once per chunk before sorting (not once per + /// comparison). Same atomicity and stability guarantees as + /// [`sort_by`](Self::sort_by). + /// + /// Requires the `set` and `atomic` features. + #[cfg(all(feature = "set", feature = "atomic"))] + pub fn sort_by_key(&mut self, mut key: impl FnMut(&[u8]) -> K) -> io::Result<()> { + let chunk_len = self.chunk_len as usize; + let start = self.aligned.start(); + let end = self.aligned.end(); + self.aligned.stack().process(start, end, |buf| { + let keys: Vec = buf.chunks_exact(chunk_len).map(&mut key).collect(); + let mut order: Vec = (0..keys.len()).collect(); + order.sort_by(|&i, &j| keys[i].cmp(&keys[j])); + apply_chunk_permutation(buf, chunk_len, &order); + }) + } + + /// Binary search for a chunk matching `cmp`, over chunks already ordered + /// by the same comparator (caller's responsibility, as with `std`). + /// + /// `cmp` should return [`Ordering::Less`] if the probed chunk sorts + /// before the target, [`Ordering::Greater`] if after, and + /// [`Ordering::Equal`] on a match — the same convention as + /// `[T]::binary_search_by`. + /// + /// Reads only the probed chunks — O(log n) chunk reads, never the whole + /// region. No feature flag beyond `alloc`: read-only. + /// + /// Returns `Ok(Ok(index))` naming a matching chunk, or `Ok(Err(index))` + /// with the index a matching chunk would need to be inserted at to keep + /// the chunks ordered. `Err` propagates an I/O failure from probing a + /// chunk. + pub fn binary_search_by( + &self, + mut cmp: impl FnMut(&[u8]) -> Ordering, + ) -> io::Result> { + let mut size = self.chunk_count(); + let mut left = 0u64; + while size > 0 { + let half = size / 2; + let mid = left + half; + let chunk = self + .get(mid) + .expect("binary_search_by: mid computed within bounds"); + let bytes = chunk.read()?; + match cmp(&bytes) { + Ordering::Less => { + left = mid + 1; + size -= half + 1; + } + Ordering::Greater => size = half, + Ordering::Equal => return Ok(Ok(mid)), + } + } + Ok(Err(left)) + } + + /// Binary search for `target` by a key extracted from each probed + /// chunk's bytes. See [`binary_search_by`](Self::binary_search_by). + #[inline] + pub fn binary_search_by_key( + &self, + target: &K, + mut key: impl FnMut(&[u8]) -> K, + ) -> io::Result> { + self.binary_search_by(|bytes| key(bytes).cmp(target)) + } + + /// Partition chunks in place so the chunk at `n` is in the position it + /// would occupy if the view were fully sorted by `cmp`; order on either + /// side of `n` is unspecified. Mirrors `[T]::select_nth_unstable_by`. + /// + /// Same single-transaction atomicity as [`sort_by`](Self::sort_by): a + /// crash leaves either the original order or a valid completed + /// partition, never a half-applied one. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Panics + /// + /// Panics if `n >= self.chunk_count()`. + #[cfg(all(feature = "set", feature = "atomic"))] + pub fn select_nth_by( + &mut self, + n: u64, + mut cmp: impl FnMut(&[u8], &[u8]) -> Ordering, + ) -> io::Result<()> { + assert!( + n < self.chunk_count(), + "select_nth_by: n must be < chunk_count" + ); + let chunk_len = self.chunk_len as usize; + let start = self.aligned.start(); + let end = self.aligned.end(); + self.aligned.stack().process(start, end, |buf| { + let count = buf.len() / chunk_len; + let mut order: Vec = (0..count).collect(); + order.select_nth_unstable_by(n as usize, |&i, &j| { + let a = &buf[i * chunk_len..(i + 1) * chunk_len]; + let b = &buf[j * chunk_len..(j + 1) * chunk_len]; + cmp(a, b) + }); + apply_chunk_permutation(buf, chunk_len, &order); + }) + } + + /// Key-extracting variant of [`select_nth_by`](Self::select_nth_by). The + /// key is computed once per chunk before partitioning. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Panics + /// + /// Panics if `n >= self.chunk_count()`. + #[cfg(all(feature = "set", feature = "atomic"))] + pub fn select_nth_by_key( + &mut self, + n: u64, + mut key: impl FnMut(&[u8]) -> K, + ) -> io::Result<()> { + assert!( + n < self.chunk_count(), + "select_nth_by_key: n must be < chunk_count" + ); + let chunk_len = self.chunk_len as usize; + let start = self.aligned.start(); + let end = self.aligned.end(); + self.aligned.stack().process(start, end, |buf| { + let keys: Vec = buf.chunks_exact(chunk_len).map(&mut key).collect(); + let mut order: Vec = (0..keys.len()).collect(); + order.select_nth_unstable_by(n as usize, |&i, &j| keys[i].cmp(&keys[j])); + apply_chunk_permutation(buf, chunk_len, &order); + }) + } +} + +impl<'a> IntoIterator for BStackChunk<'a> { + type Item = BStackSlice<'a>; + type IntoIter = BStackChunkIter<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + BStackChunkIter { + remaining: self.aligned, + chunk_len: self.chunk_len, + } + } +} + +impl<'a> IntoIterator for &BStackChunk<'a> { + type Item = BStackSlice<'a>; + type IntoIter = BStackChunkIter<'a>; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// Sort `buf`, treated as consecutive `chunk_len`-byte records, in place. +/// +/// Sorts a proxy index array by `cmp` (stable, per `[usize]::sort_by`) then +/// applies the resulting permutation to `buf` in one pass, rather than +/// moving records during comparison. +#[cfg(all(feature = "set", feature = "atomic"))] +fn sort_chunks_by(buf: &mut [u8], chunk_len: usize, cmp: &mut dyn FnMut(&[u8], &[u8]) -> Ordering) { + let count = buf.len() / chunk_len; + if count <= 1 { + return; + } + let mut order: Vec = (0..count).collect(); + order.sort_by(|&i, &j| { + let a = &buf[i * chunk_len..(i + 1) * chunk_len]; + let b = &buf[j * chunk_len..(j + 1) * chunk_len]; + cmp(a, b) + }); + apply_chunk_permutation(buf, chunk_len, &order); +} + +/// Chunk-sized scratch buffers up to this many bytes live on the stack; +/// larger ones fall back to a heap allocation. Covers the common case (short +/// fixed-width records) without a per-call allocation. +#[cfg(all(feature = "set", feature = "atomic"))] +const INLINE_SCRATCH_LEN: usize = 128; + +/// Reorder `buf`'s `chunk_len`-byte records so record `order[dest]` ends up +/// at position `dest`, in place via cycle-following. +/// +/// Only two scratch allocations exist for the whole call: `visited` (one +/// bool per record) and a single `chunk_len`-byte swap buffer — never a +/// second copy of the whole region. The swap buffer is stack-allocated for +/// `chunk_len <= INLINE_SCRATCH_LEN`. +#[cfg(all(feature = "set", feature = "atomic"))] +fn apply_chunk_permutation(buf: &mut [u8], chunk_len: usize, order: &[usize]) { + let mut inline = [0u8; INLINE_SCRATCH_LEN]; + let mut heap; + let temp: &mut [u8] = if chunk_len <= INLINE_SCRATCH_LEN { + &mut inline[..chunk_len] + } else { + heap = vec![0u8; chunk_len]; + &mut heap[..] + }; + + let mut visited = vec![false; order.len()]; + for start in 0..order.len() { + if visited[start] || order[start] == start { + visited[start] = true; + continue; + } + temp.copy_from_slice(&buf[start * chunk_len..(start + 1) * chunk_len]); + let mut cur = start; + while order[cur] != start { + let src = order[cur]; + buf.copy_within(src * chunk_len..(src + 1) * chunk_len, cur * chunk_len); + visited[cur] = true; + cur = src; + } + buf[cur * chunk_len..(cur + 1) * chunk_len].copy_from_slice(temp); + visited[cur] = true; + } +} + +impl<'a> BStackSlice<'a> { + /// Divide this slice into `chunk_len`-byte chunks, aligned from the + /// start, returning `(chunk_view, remainder)`. Any leftover bytes + /// (`self.len() % chunk_len`) come back as the *trailing* remainder + /// slice; it is empty if `chunk_len` evenly divides `self.len()`. + /// + /// No I/O — pure offset arithmetic, as cheap as + /// [`subslice`](Self::subslice). + /// + /// # Panics + /// + /// Panics if `chunk_len == 0`. + pub fn chunks(&self, chunk_len: u64) -> (BStackChunk<'a>, BStackSlice<'a>) { + assert!(chunk_len > 0, "chunks: chunk_len must be nonzero"); + let len = self.len(); + let aligned_len = (len / chunk_len) * chunk_len; + let chunk = BStackChunk { + aligned: self.subslice(0, aligned_len), + chunk_len, + }; + (chunk, self.subslice(aligned_len, len)) + } + + /// Divide this slice into `chunk_len`-byte chunks, aligned from the end, + /// returning `(chunk_view, remainder)`. Any leftover bytes come back as + /// the *leading* remainder slice. + /// + /// Produces the same [`BStackChunk`] type as [`chunks`](Self::chunks); + /// the two differ only in which sub-region ends up aligned and which + /// ends up in the returned remainder. + /// + /// No I/O — pure offset arithmetic. + /// + /// # Panics + /// + /// Panics if `chunk_len == 0`. + pub fn rchunks(&self, chunk_len: u64) -> (BStackChunk<'a>, BStackSlice<'a>) { + assert!(chunk_len > 0, "rchunks: chunk_len must be nonzero"); + let len = self.len(); + let rem_len = len % chunk_len; + let chunk = BStackChunk { + aligned: self.subslice(rem_len, len), + chunk_len, + }; + (chunk, self.subslice(0, rem_len)) + } +} + +impl<'a, A: BStackAllocator> BStackOwnedSlice<'a, A> { + /// Borrowed equivalent of [`BStackSlice::chunks`]. + /// + /// Internally borrows a [`BStackSlice`] via [`as_slice`](Self::as_slice); + /// the returned view and remainder's lifetime is tied to `&self`. + #[inline] + pub fn chunks<'s>(&'s self, chunk_len: u64) -> (BStackChunk<'s>, BStackSlice<'s>) { + self.as_slice().chunks(chunk_len) + } + + /// Borrowed equivalent of [`BStackSlice::rchunks`]. + /// + /// Internally borrows a [`BStackSlice`] via [`as_slice`](Self::as_slice); + /// the returned view and remainder's lifetime is tied to `&self`. + #[inline] + pub fn rchunks<'s>(&'s self, chunk_len: u64) -> (BStackChunk<'s>, BStackSlice<'s>) { + self.as_slice().rchunks(chunk_len) + } +} + +/// A lazy, zero-I/O iterator over the chunks of a [`BStackChunk`]. +/// +/// Each step (`next`/`next_back`) is pure offset arithmetic — the same cost +/// as [`BStackSlice::subslice`] — and performs no I/O by itself. Actual bytes +/// are only read when the caller calls `.read()`/`.read_into()` on an +/// individual yielded [`BStackSlice`]; the chunked region is never +/// materialized into memory as a whole by this iterator, regardless of how +/// many chunks it spans. +/// +/// Constructed by [`BStackChunk::iter`] or `IntoIterator`. +pub struct BStackChunkIter<'a> { + remaining: BStackSlice<'a>, + chunk_len: u64, +} + +impl<'a> Clone for BStackChunkIter<'a> { + #[inline] + fn clone(&self) -> Self { + BStackChunkIter { + remaining: self.remaining.clone(), + chunk_len: self.chunk_len, + } + } +} + +impl<'a> fmt::Debug for BStackChunkIter<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BStackChunkIter") + .field("remaining_len", &self.remaining.len()) + .field("chunk_len", &self.chunk_len) + .finish_non_exhaustive() + } +} + +impl<'a> Iterator for BStackChunkIter<'a> { + type Item = BStackSlice<'a>; + + fn next(&mut self) -> Option { + if self.remaining.len() < self.chunk_len { + return None; + } + let head = self.remaining.subslice(0, self.chunk_len); + self.remaining = self + .remaining + .subslice(self.chunk_len, self.remaining.len()); + Some(head) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let n = (self.remaining.len() / self.chunk_len) as usize; + (n, Some(n)) + } +} + +impl<'a> DoubleEndedIterator for BStackChunkIter<'a> { + fn next_back(&mut self) -> Option { + if self.remaining.len() < self.chunk_len { + return None; + } + let len = self.remaining.len(); + let tail = self.remaining.subslice(len - self.chunk_len, len); + self.remaining = self.remaining.subslice(0, len - self.chunk_len); + Some(tail) + } +} + +impl<'a> ExactSizeIterator for BStackChunkIter<'a> {} + +impl<'a> std::iter::FusedIterator for BStackChunkIter<'a> {} From b47903a133f1ae10aa1eb3004336b80a4791439a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 20:26:50 -0700 Subject: [PATCH 3/8] Integration --- README.md | 36 +++++++++++++++++++++++++++++++++++- src/alloc/mod.rs | 7 +++++++ src/lib.rs | 6 +++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dc84933..4472b91 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,16 @@ assert!(stack.pop(stack.len()? - 60).is_err()); // would shrink below locked together directly without an explicit conversion. See [Slice Location Equality](#slice-location-equality) below for what this comparison does and does not mean. +**`BStackChunk<'a>`** — fixed-stride view carrying `&'a BStack`. Non-`Copy`, `Clone`. + +| Trait | Semantics | +|----------------------|---------------------------------------------------------------------------------------------| +| `PartialEq` / `Eq` | Compares `(chunk_len, aligned region)` — same stride *and* same underlying `(offset, len)`. | +| `Hash` | Hashes `(chunk_len, aligned region)`. | +| `PartialOrd` / `Ord` | Ordered by `chunk_len` first, then by the aligned region's own `Ord`. | + +Deliberately **not** cross-comparable with `BStackSlice`/`BStackOwnedSlice`/`BStackRange` — a chunk view's stride is part of its identity, and comparing it directly against a bare slice would silently discard that. + ### `BStackSliceReader` and `BStackSliceWriter` (`alloc` / `alloc + set` features) | Trait | Semantics | @@ -771,16 +781,19 @@ The `alloc` feature adds typed region management over a `BStack` payload. ### Region handle design -The `alloc` feature provides three distinct handle types for different roles: +The `alloc` feature provides four distinct handle types for different roles: | Type | Carries | Copy | I/O | Alloc ops | |---------------------------|--------------|------|----------|-----------| | `BStackRange` | nothing | yes | no | no | | `BStackOwnedSlice<'a, A>` | `&'a A` | no | via view | yes | | `BStackSlice<'a>` | `&'a BStack` | no | yes | no | +| `BStackChunk<'a>` | `&'a BStack` | no | yes | no | `BStackOwnedSlice` is non-`Copy` and non-`Clone`: an allocation has exactly one owner. Obtaining an I/O view via `as_slice()` or `as_slice_mut()` ties the view's lifetime to the borrow of the owned slice, preventing it from outliving the handle. `BStackSlice` is non-`Copy` so that `write*(&mut self)` provides single-writer exclusivity; it is `Clone` for explicit second views. +`BStackChunk` sits at the same semantic position as `BStackSlice` — same `Carries`/`Copy`/`I/O`/`Alloc ops` columns, same non-`Copy`-but-`Clone` rationale — it is simply a `BStackSlice` with a fixed stride layered on top (see "`BStackChunk<'a>` — fixed-stride chunked view" below). It is not an iterator itself and has no allocator operations of its own. + ### `BStackAllocator` trait A trait for types that own a `BStack` and manage contiguous byte regions @@ -974,6 +987,27 @@ A raw `(offset, len)` coordinate pair with no backing reference. `Copy`, seriali A cursor-based reader over a `BStackSlice`. Implements `io::Read` and `io::Seek`. +### `BStackChunk<'a>` — fixed-stride chunked view + +A "slice with a stride": divides a region into `chunk_len`-byte records. Sits at the same semantic position as `BStackSlice` — carries `&'a BStack` directly, no allocator operations — but is a **view**, not an iterator; it does not implement `Iterator`. Non-`Copy`, `Clone`, same rationale as `BStackSlice`. + +Obtained from `BStackSlice::chunks(chunk_len)` / `BStackSlice::rchunks(chunk_len)` (mirrored on `BStackOwnedSlice`), each returning **`(BStackChunk<'a>, BStackSlice<'a>)`**: the aligned chunk view, plus whatever bytes are left over if `chunk_len` doesn't evenly divide the source length. `chunks` aligns from the start (leftover at the tail); `rchunks` aligns from the end (leftover at the head). No I/O — pure offset arithmetic, as cheap as `subslice`. + +| Method | Description | +|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------| +| `chunk_len()` / `chunk_count()` / `len()` / `is_empty()` | Stride, chunk count, and total aligned byte length | +| `get(index)` | The chunk at `index` as a `BStackSlice`, or `None` — O(1), no I/O | +| `as_slice()` / `into_slice()` | The whole aligned region as a plain `BStackSlice` — by clone, or by consuming `self` | +| `with_stride(new_stride)` | Consume `self`, re-dividing the aligned region with a different stride — `(BStackChunk, BStackSlice)`, same as `chunks` | +| `iter()` / `IntoIterator` | A lazy `BStackChunkIter` (see below); usable directly in a `for` loop, by value or `&view` | +| `binary_search_by(cmp)` / `binary_search_by_key(target, key)` | Binary search over already-ordered chunks — O(log n) chunk reads, never the whole region. | +| `sort_by(cmp)` / `sort_by_key(key)` *(features `set` + `atomic`)* | Stable sort of whole chunks by their bytes/a key. One `BStack::process` call | +| `select_nth_by(n, cmp)` / `select_nth_by_key(n, key)` *(features `set` + `atomic`)* | Partition so chunk `n` lands where a full sort would place it (`[T]::select_nth_unstable_by`); single-transaction atomic | + +**`BStackChunkIter`** — the lazy iterator returned by `iter()`/`IntoIterator`. `Item = BStackSlice<'a>`. Each `next()`/`next_back()` (it's `DoubleEndedIterator` + `ExactSizeIterator` + `FusedIterator`) is pure offset arithmetic and performs **no I/O**; actual bytes are only read when the caller calls `.read()` on an individual yielded chunk, one at a time — the chunked region is never materialized into memory as a whole by the iterator itself, regardless of size. (`sort_by`/`select_nth_by` are the exception: they intentionally read the whole aligned region at once, to commit as one crash-atomic transaction — a different, opt-in tradeoff from plain iteration.) + +**`PartialEq`/`Eq`/`Hash`/`PartialOrd`/`Ord` for `BStackChunk`** — location equality/ordering over `(chunk_len, aligned_region)`: equal only when both the stride *and* the underlying region match; ordered first by stride, then by the region's own `Ord`. Unlike `BStackSlice`/`BStackOwnedSlice`/`BStackRange`, there is deliberately **no** cross-type comparison against a bare `BStackSlice` — a chunk view's stride is part of its identity, and comparing it directly to a slice would silently discard that. + ### Slice Location Equality `BStackSlice`, `BStackOwnedSlice`, and `BStackRange` implement `PartialEq` **and** `PartialOrd` against each other — every pairing, both directions (`BStackSlice` ↔ `BStackSlice`, `BStackOwnedSlice` ↔ `BStackOwnedSlice`, `BStackSlice` ↔ `BStackOwnedSlice`, `BStackRange` ↔ `BStackSlice`, `BStackRange` ↔ `BStackOwnedSlice`). diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index c9e46c4..971d860 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -23,6 +23,11 @@ //! * [`BStackSliceWriter`] — a cursor-based writer ([`io::Write`] + [`io::Seek`], //! `set` feature). //! +//! * [`BStackChunk<'a>`](BStackChunk) — a fixed-stride **view**, not an +//! iterator, from [`BStackSlice::chunks`]/[`rchunks`](BStackSlice::rchunks). +//! [`iter`](BStackChunk::iter) gives a lazy [`BStackChunkIter`]; `sort_by`, +//! `binary_search_by`, and `select_nth_by` operate on the view directly. +//! //! * [`BStackAllocator`] — allocator trait. `alloc`/`realloc`/`dealloc` //! take and return `Self::Allocated<'a>`, which must implement //! `Into>`. [`into_stack`](BStackAllocator::into_stack) @@ -136,7 +141,9 @@ use crate::BStack; use std::fmt; use std::io; +pub mod chunk; pub mod slice; +pub use chunk::{BStackChunk, BStackChunkIter}; #[cfg(feature = "set")] pub use slice::BStackSliceWriter; pub use slice::{BStackOwnedSlice, BStackRange, BStackSlice, BStackSliceReader}; diff --git a/src/lib.rs b/src/lib.rs index 6e1d9f9..03f4f49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -628,9 +628,9 @@ mod test; mod alloc; #[cfg(feature = "alloc")] pub use alloc::{ - BStackAllocError, BStackAllocator, BStackBulkAllocError, BStackBulkAllocator, BStackOwnedSlice, - BStackOwnedSliceAllocator, BStackRange, BStackSlice, BStackSliceReader, BStackUninitAllocator, - DebugCheckingAllocator, LinearBStackAllocator, + BStackAllocError, BStackAllocator, BStackBulkAllocError, BStackBulkAllocator, BStackChunk, + BStackChunkIter, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange, BStackSlice, + BStackSliceReader, BStackUninitAllocator, DebugCheckingAllocator, LinearBStackAllocator, }; #[cfg(all(feature = "alloc", feature = "set"))] pub use alloc::{ From 87f1fd8735bded679d0ff4cdd6162c138401b375 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 20:27:01 -0700 Subject: [PATCH 4/8] Add tests --- src/test.rs | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/src/test.rs b/src/test.rs index d2c5296..e4fab42 100644 --- a/src/test.rs +++ b/src/test.rs @@ -3488,6 +3488,330 @@ mod alloc_tests { assert_eq!(new[1].start(), 12); let _ = slices; // keep the first slice alive } + + // ---- BStackChunk: construction, remainder, iteration -------------------- + + // 1. chunks() aligns from the start; leftover bytes are the tail remainder. + #[test] + fn chunks_basic_and_remainder() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(10).unwrap(); // 10 bytes, chunk_len 3 -> 3 chunks + 1 remainder + let (view, rem) = s.as_slice().chunks(3); + assert_eq!(view.chunk_len(), 3); + assert_eq!(view.chunk_count(), 3); + assert_eq!(view.len(), 9); + assert!(!view.is_empty()); + assert_eq!(rem.len(), 1); + assert_eq!(rem.start(), 9); // trailing remainder + } + + // 2. rchunks() aligns from the end; leftover bytes are the head remainder. + #[test] + fn rchunks_basic_and_remainder() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(10).unwrap(); + let (view, rem) = s.as_slice().rchunks(3); + assert_eq!(view.chunk_count(), 3); + assert_eq!(view.len(), 9); + assert_eq!(rem.len(), 1); + assert_eq!(rem.start(), 0); // leading remainder + assert_eq!(view.as_slice().start(), 1); // aligned region starts after remainder + } + + // 3. An evenly-divisible length has an empty remainder either way. + #[test] + fn chunks_no_remainder_when_evenly_divisible() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(9).unwrap(); + assert!(s.as_slice().chunks(3).1.is_empty()); + assert!(s.as_slice().rchunks(3).1.is_empty()); + } + + // 4. chunk_len == 0 panics for both constructors. + #[test] + #[should_panic(expected = "chunk_len must be nonzero")] + fn chunks_zero_chunk_len_panics() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + let _ = s.as_slice().chunks(0); + } + + #[test] + #[should_panic(expected = "chunk_len must be nonzero")] + fn rchunks_zero_chunk_len_panics() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + let _ = s.as_slice().rchunks(0); + } + + // 5. get() returns the correct bytes at each index and None out of bounds. + #[cfg(feature = "set")] + #[test] + fn chunk_get_by_index() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(6).unwrap(); + s.as_slice_mut().write([1u8, 2, 3, 4, 5, 6]).unwrap(); + let (view, _rem) = s.as_slice().chunks(2); + assert_eq!(view.get(0).unwrap().read().unwrap(), [1, 2]); + assert_eq!(view.get(1).unwrap().read().unwrap(), [3, 4]); + assert_eq!(view.get(2).unwrap().read().unwrap(), [5, 6]); + assert!(view.get(3).is_none()); + } + + // 6. iter() yields chunks in order without reading anything until asked. + #[cfg(feature = "set")] + #[test] + fn chunk_iter_forward() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(6).unwrap(); + s.as_slice_mut().write([1u8, 2, 3, 4, 5, 6]).unwrap(); + let (view, _rem) = s.as_slice().chunks(2); + let collected: Vec> = view.iter().map(|c| c.read().unwrap()).collect(); + assert_eq!(collected, vec![vec![1, 2], vec![3, 4], vec![5, 6]]); + } + + // 7. The iterator is double-ended and exact-sized. + #[cfg(feature = "set")] + #[test] + fn chunk_iter_double_ended_and_sized() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(6).unwrap(); + s.as_slice_mut().write([1u8, 2, 3, 4, 5, 6]).unwrap(); + let (view, _rem) = s.as_slice().chunks(2); + let mut it = view.iter(); + assert_eq!(it.len(), 3); + assert_eq!(it.next().unwrap().read().unwrap(), [1, 2]); + assert_eq!(it.next_back().unwrap().read().unwrap(), [5, 6]); + assert_eq!(it.len(), 1); + assert_eq!(it.next().unwrap().read().unwrap(), [3, 4]); + assert!(it.next().is_none()); + assert!(it.next_back().is_none()); + } + + // 8. BStackChunk is usable directly in a `for` loop, by value and by + // reference, via IntoIterator. + #[cfg(feature = "set")] + #[test] + fn chunk_into_iterator() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(4).unwrap(); + s.as_slice_mut().write([1u8, 2, 3, 4]).unwrap(); + let (view, _rem) = s.as_slice().chunks(2); + let mut total = 0u32; + for chunk in &view { + total += chunk.read().unwrap().iter().map(|&b| b as u32).sum::(); + } + assert_eq!(total, 10); + let mut count = 0; + for _chunk in view { + count += 1; + } + assert_eq!(count, 2); + } + + // 9. BStackOwnedSlice::chunks/rchunks mirror BStackSlice's. + #[cfg(feature = "set")] + #[test] + fn owned_slice_chunks_mirror() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(6).unwrap(); + s.write([1u8, 2, 3, 4, 5, 6]).unwrap(); + assert_eq!(s.chunks(2).0.chunk_count(), 3); + assert_eq!(s.rchunks(4).1.len(), 2); + } + + // ---- BStackChunk: sort / search / select --------------------------------- + + // 10. sort_by reorders whole 3-byte records by their first byte, leaving + // each record's remaining bytes attached and the remainder untouched. + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn chunk_sort_by_orders_records() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(10).unwrap(); // 3 records of 3 bytes + 1 remainder byte + s.as_slice_mut() + .write([3u8, b'c', b'C', 1, b'a', b'A', 2, b'b', b'B', 0xFF]) + .unwrap(); + let (mut view, _rem) = s.as_slice().chunks(3); + view.sort_by(|a, b| a[0].cmp(&b[0])).unwrap(); + let sorted = s.as_slice().read().unwrap(); + assert_eq!(sorted, [1, b'a', b'A', 2, b'b', b'B', 3, b'c', b'C', 0xFF]); + } + + // 11. sort_by_key: same as sort_by but keyed, and stable on equal keys. + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn chunk_sort_by_key_is_stable() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(8).unwrap(); // 4 records of 2 bytes: key, tag + s.as_slice_mut() + .write([1u8, b'x', 0, b'a', 1, b'y', 0, b'b']) + .unwrap(); + let (mut view, _rem) = s.as_slice().chunks(2); + view.sort_by_key(|c| c[0]).unwrap(); + let sorted = s.as_slice().read().unwrap(); + // Both key-0 records keep their relative order (a before b), likewise key-1. + assert_eq!(sorted, [0, b'a', 0, b'b', 1, b'x', 1, b'y']); + } + + // 12. binary_search_by finds a present chunk and reports an insertion + // point for an absent one, over already-ordered chunks. + #[cfg(feature = "set")] + #[test] + fn chunk_binary_search_by_found_and_not_found() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(8).unwrap(); // 4 ordered 2-byte records: keys 1,3,5,7 + s.as_slice_mut().write([1u8, 0, 3, 0, 5, 0, 7, 0]).unwrap(); + let (view, _rem) = s.as_slice().chunks(2); + assert_eq!(view.binary_search_by(|c| c[0].cmp(&5)).unwrap(), Ok(2)); + assert_eq!(view.binary_search_by(|c| c[0].cmp(&4)).unwrap(), Err(2)); + assert_eq!(view.binary_search_by(|c| c[0].cmp(&0)).unwrap(), Err(0)); + assert_eq!(view.binary_search_by(|c| c[0].cmp(&8)).unwrap(), Err(4)); + } + + // 13. binary_search_by_key delegates to binary_search_by via a key fn. + #[cfg(feature = "set")] + #[test] + fn chunk_binary_search_by_key_works() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(6).unwrap(); + s.as_slice_mut().write([2u8, 4, 6, 8, 10, 12]).unwrap(); + let (view, _rem) = s.as_slice().chunks(2); + assert_eq!(view.binary_search_by_key(&6, |c| c[0]).unwrap(), Ok(1)); + assert_eq!(view.binary_search_by_key(&7, |c| c[0]).unwrap(), Err(2)); + } + + // 14. select_nth_by places the nth chunk where it would land in a full + // sort; every chunk before it compares <=, every chunk after >=. + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn chunk_select_nth_by_partitions() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(5).unwrap(); // 5 single-byte records + s.as_slice_mut().write([5u8, 1, 4, 2, 3]).unwrap(); + let (mut view, _rem) = s.as_slice().chunks(1); + view.select_nth_by(2, |a, b| a[0].cmp(&b[0])).unwrap(); + let buf = s.as_slice().read().unwrap(); + assert_eq!(buf[2], 3); // the median of 1..=5 lands at index 2 + assert!(buf[..2].iter().all(|&v| v <= 3)); + assert!(buf[3..].iter().all(|&v| v >= 3)); + } + + // 15. select_nth_by_key mirrors select_nth_by with a key function. + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn chunk_select_nth_by_key_partitions() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(5).unwrap(); + s.as_slice_mut().write([5u8, 1, 4, 2, 3]).unwrap(); + let (mut view, _rem) = s.as_slice().chunks(1); + view.select_nth_by_key(2, |c| c[0]).unwrap(); + let buf = s.as_slice().read().unwrap(); + assert_eq!(buf[2], 3); + } + + // 16. select_nth_by panics when n is out of bounds. + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + #[should_panic(expected = "n must be < chunk_count")] + fn chunk_select_nth_by_out_of_bounds_panics() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(3).unwrap(); + let (mut view, _rem) = s.as_slice().chunks(1); + view.select_nth_by(3, |a, b| a.cmp(b)).unwrap(); + } + + // 17. sort_by correctly applies a permutation containing multiple + // disjoint cycles (a 2-cycle and a 4-cycle), exercising the + // in-place cycle-following permutation logic beyond a single cycle. + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn chunk_sort_by_multiple_cycles() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(6).unwrap(); + s.as_slice_mut().write([5u8, 3, 1, 4, 2, 0]).unwrap(); + let (mut view, _rem) = s.as_slice().chunks(1); + view.sort_by(|a, b| a.cmp(b)).unwrap(); + assert_eq!(s.as_slice().read().unwrap(), [0, 1, 2, 3, 4, 5]); + } + + // 18. into_slice() consumes the view and returns the aligned region. + #[test] + fn chunk_into_slice_returns_aligned_region() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(10).unwrap(); + let (view, _rem) = s.as_slice().chunks(3); + let aligned = view.into_slice(); + assert_eq!(aligned.start(), 0); + assert_eq!(aligned.len(), 9); + } + + // 19. with_stride() re-divides the aligned region with a new stride. + #[test] + fn chunk_with_stride_rechunks_aligned_region() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(10).unwrap(); + let (view, _rem) = s.as_slice().chunks(3); // aligned: 9 bytes, 1-byte remainder + let (restrided, new_rem) = view.with_stride(4); + assert_eq!(restrided.chunk_len(), 4); + assert_eq!(restrided.chunk_count(), 2); // 9 / 4 = 2, with 1 byte left + assert_eq!(new_rem.len(), 1); + } + + // 20. PartialEq/Eq: same underlying region and same stride compare + // equal; a different stride or a different region does not. + #[test] + fn chunk_partial_eq_requires_same_slice_and_stride() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(8).unwrap(); + let (a, _) = s.as_slice().chunks(2); + let (b, _) = s.as_slice().chunks(2); + assert_eq!(a, b); // same region, same stride + let (c, _) = s.as_slice().chunks(4); + assert_ne!(a, c); // same region, different stride + let other = alloc.alloc(8).unwrap(); + let (d, _) = other.as_slice().chunks(2); + assert_ne!(a, d); // different region, same stride + } + + // 21. PartialOrd/Ord: ordered first by stride, then by the underlying + // region. + #[test] + fn chunk_partial_ord_orders_by_stride_then_slice() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(8).unwrap(); + let (small_stride, _) = s.as_slice().chunks(2); + let (large_stride, _) = s.as_slice().chunks(4); + assert!(small_stride < large_stride); // stride 2 < stride 4, regardless of region + + let first = alloc.alloc(4).unwrap(); + let second = alloc.alloc(4).unwrap(); + let (first_view, _) = first.as_slice().chunks(2); + let (second_view, _) = second.as_slice().chunks(2); + assert!(first_view < second_view); // same stride, earlier offset sorts first + } } // ------------------------------------------------------------------------- From c1070cdebe437ec4f8f4a468269de27aa9bed93e Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 20:33:09 -0700 Subject: [PATCH 5/8] Add CHANGELOG entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98e9bb..8bb1139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`BStackSlice`, `BStackOwnedSlice`, and `BStackRange` — cross-type `PartialEq` and `PartialOrd` (`alloc`).** Every pairing among the three, both directions, compares/orders on `(offset, len)` coordinates only — location, not content — and performs no I/O. `PartialOrd` matches each type's own `Ord` (by `offset`, then `len`). `BStackByteVec` deliberately does not participate in either trait: a meaningful comparison would require reading its header to resolve `len`, and `==`/`<` should not perform I/O silently. - **`BStackSlice` — `std`-slice-style ergonomic methods (`alloc`).** Read-only, no extra feature: `get(index)`, `head(n)`/`tail(n)`, `contains(byte)`, `starts_with`/`ends_with`, `find`/`rfind`, `position`/`rposition`, `split_at`/`split_at_mut`. Write methods (`set`): `fill(value)` (single `BStack::repeat` call), `fill_with(f)`, `copy_from_slice(src)`. Atomic compound writes (`set` + `atomic`, each a single crash-atomic `BStack` call): `copy_from_bstack_slice`, `copy_within`, `swap` (via `cross_exchange`), `reverse`, `rotate_left`/`rotate_right` (via `process`). `BStackOwnedSlice` mirrors the full set, delegating through `as_slice()`/`as_slice_mut()`. +- **`BStackChunk<'a>`/`BStackChunkIter<'a>` — fixed-stride view over `BStackSlice` (`alloc`).** `BStackSlice::chunks`/`rchunks` (mirrored on `BStackOwnedSlice`) return `(BStackChunk, BStackSlice)`: aligned view + remainder, pure offset arithmetic, no I/O. `as_slice`/`into_slice`/`with_stride` recover or re-chunk the aligned region. `PartialEq`/`Eq`/`Hash`/`PartialOrd`/`Ord` on `(chunk_len, region)`; no cross-type comparison with `BStackSlice`. Not an iterator itself: `iter()`/`IntoIterator` yield a `BStackChunkIter` (`DoubleEndedIterator` + `ExactSizeIterator` + `FusedIterator`), zero I/O per step. +- **`BStackChunk` search/sort/select.** `binary_search_by`/`binary_search_by_key` (`alloc`): O(log n) chunk reads. `sort_by`/`sort_by_key`/`select_nth_by`/`select_nth_by_key` (`set` + `atomic`): one crash-atomic `BStack::process` call, in-place cycle-following permutation (O(1) scratch chunks, stack-allocated ≤128 B); `select_nth_*` mirrors `[T]::select_nth_unstable_by`. ## [0.4.1] - 2026-08-03 From 56cedfaa35ef3da1d8f0b31fdaded5e34d8c4574 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 20:37:53 -0700 Subject: [PATCH 6/8] Add new PLANNED.md section --- PLANNED.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/PLANNED.md b/PLANNED.md index a3e49c9..625a661 100644 --- a/PLANNED.md +++ b/PLANNED.md @@ -130,3 +130,33 @@ The mutex's scope and implementation are not in question here (see the `NOT PLAN ### Open questions - **Validation.** Whether `mixed/uniform` workload is sufficient to measure improvement, or whether a contention-specific microbenchmark (concurrent non-tail alloc/dealloc only) is needed to isolate the critical-section-size effect. + +## External-merge-sort strategy and partial sort for `BStackChunk` + +**Feature flag:** `alloc` + `set` + `atomic`, same as the `BStackChunk::sort_by`/`select_nth_by` family it extends. +**Breaking change:** No — purely additive; the current single-transaction `sort_by`/`sort_by_key`/`select_nth_by`/`select_nth_by_key` keep their existing behavior and signatures. + +### Motivation + +`BStackChunk::sort_by`/`sort_by_key`/`select_nth_by`/`select_nth_by_key` commit via a single `BStack::process` call: the aligned region is read whole, permuted in place (cycle-following, O(1) scratch chunks), and written back atomically. This is bounded by available memory — a region too large for one `Vec` can't be sorted this way. The staged small/medium/large strategy below would lift that bound; it was cut from the initial implementation as extra scope, not for lack of an atomicity primitive — `BStack::set_batched`/`inplace_gen` (`set` + `atomic`) already commit an arbitrary batch of non-overlapping writes as one crash-atomic multi-write-journal transaction, and are the natural fit for committing a multi-section sort's final output in one shot. + +### Design + +- **Section sort + merge**, applied recursively (external sort is section-sort-then-merge at every scale — "medium" and "large" from the original sketch are the same algorithm, just more sections and more merge passes; "small," i.e. what's shipped today, is the degenerate one-section case): + 1. Split the aligned region into sections that individually fit in memory. + 2. Sort each section in place (today's `sort_by`/`sort_by_key`, applied per-section). + 3. Merge adjacent sorted sections, repeating merge passes until one fully-sorted region remains. + + Section size and the small/medium/large threshold are implementation details (fixed constant, fraction of available memory, or caller-configurable), not part of the public contract. + +- **Whole-sort atomicity across sections, via `BStack::set_batched`.** The final merge pass streams `(new_offset, chunk_bytes)` pairs — produced lazily while scanning the sorted sections, not all held in memory at once — into one `set_batched` call, so the whole multi-section sort commits as a single crash-atomic transaction: a crash leaves either the pre-sort order or the fully-sorted order, never an intermediate state. Only the final section-to-final-position write needs batching this way; each section's own in-place sort (step 2) stays on the existing per-section `process` call. + +- **`sort_partial_by`/`sort_partial_by_key`** — best-effort: pushes through as many sections/merge passes as it can rather than stopping early by choice, and returns `Err` only for a genuine I/O failure, never merely for running out of passes or budget. "Partial" only ever means *not fully ordered*, never corrupted or lost data: every completed step (section sort, merge pass) is itself committed atomically, so the visible state on return is always some valid permutation of the original records, however far the sort got. + +- **`select_nth_by`/`select_nth_by_key` for out-of-core data.** Quickselect's whole point is avoiding a full sort, so the section-and-merge strategy above doesn't directly apply — it needs its own out-of-core partitioning approach (e.g. section-local partitioning plus a pivot-selection pass across sections) rather than assuming in-memory partitioning over the whole range. + +### Open questions + +- **Unstable sort.** Whether to also provide `sort_unstable_by`/`sort_unstable_by_key`, mirroring `std`'s stable/unstable split — unstable sort does fewer chunk moves at the cost of stability, which may matter more once movement is spread across multiple sections/passes. +- **Threshold sizing.** How the small/medium/large boundary and section size are chosen, and whether it should be caller-configurable given the memory/atomicity tradeoff is now explicit. +- **Batch staging cost.** `set_batched` still stages every block's bytes into the multi-write journal before committing, so the final pass's staging cost scales with the number and size of the (new_offset, chunk_bytes) pairs it's fed — worth measuring against the current single-`process` cost before committing to this as the merge-commit strategy. From bb7e95ba55a1433c0df558a96c7bd8dad8550aaf Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 20:55:51 -0700 Subject: [PATCH 7/8] Clamp to usize:max --- README.md | 2 +- src/alloc/chunk.rs | 9 ++++++++- src/test.rs | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4472b91..3f84d1f 100644 --- a/README.md +++ b/README.md @@ -1004,7 +1004,7 @@ Obtained from `BStackSlice::chunks(chunk_len)` / `BStackSlice::rchunks(chunk_len | `sort_by(cmp)` / `sort_by_key(key)` *(features `set` + `atomic`)* | Stable sort of whole chunks by their bytes/a key. One `BStack::process` call | | `select_nth_by(n, cmp)` / `select_nth_by_key(n, key)` *(features `set` + `atomic`)* | Partition so chunk `n` lands where a full sort would place it (`[T]::select_nth_unstable_by`); single-transaction atomic | -**`BStackChunkIter`** — the lazy iterator returned by `iter()`/`IntoIterator`. `Item = BStackSlice<'a>`. Each `next()`/`next_back()` (it's `DoubleEndedIterator` + `ExactSizeIterator` + `FusedIterator`) is pure offset arithmetic and performs **no I/O**; actual bytes are only read when the caller calls `.read()` on an individual yielded chunk, one at a time — the chunked region is never materialized into memory as a whole by the iterator itself, regardless of size. (`sort_by`/`select_nth_by` are the exception: they intentionally read the whole aligned region at once, to commit as one crash-atomic transaction — a different, opt-in tradeoff from plain iteration.) +**`BStackChunkIter`** — the lazy iterator returned by `iter()`/`IntoIterator`. `Item = BStackSlice<'a>`. Each `next()`/`next_back()` (it's `DoubleEndedIterator` + `ExactSizeIterator` + `FusedIterator`) is pure offset arithmetic and performs **no I/O**; actual bytes are only read when the caller calls `.read()` on an individual yielded chunk, one at a time — the chunked region is never materialized into memory as a whole by the iterator itself, regardless of size. (`sort_by`/`select_nth_by` are the exception: they intentionally read the whole aligned region at once, to commit as one crash-atomic transaction — a different, opt-in tradeoff from plain iteration.) The chunk count is tracked as `u64` and `size_hint()`/`len()` are exact on 64-bit targets; on targets where `usize` is narrower than `u64`, a count that overflows `usize` clamps to `usize::MAX` rather than wrapping. **`PartialEq`/`Eq`/`Hash`/`PartialOrd`/`Ord` for `BStackChunk`** — location equality/ordering over `(chunk_len, aligned_region)`: equal only when both the stride *and* the underlying region match; ordered first by stride, then by the region's own `Ord`. Unlike `BStackSlice`/`BStackOwnedSlice`/`BStackRange`, there is deliberately **no** cross-type comparison against a bare `BStackSlice` — a chunk view's stride is part of its identity, and comparing it directly to a slice would silently discard that. diff --git a/src/alloc/chunk.rs b/src/alloc/chunk.rs index 6ce99ff..17f6244 100644 --- a/src/alloc/chunk.rs +++ b/src/alloc/chunk.rs @@ -506,6 +506,13 @@ impl<'a, A: BStackAllocator> BStackOwnedSlice<'a, A> { /// materialized into memory as a whole by this iterator, regardless of how /// many chunks it spans. /// +/// Implements [`ExactSizeIterator`]: the chunk count is tracked internally as +/// `u64` and is exact on 64-bit targets. On targets where `usize` is +/// narrower than `u64` (e.g. 32-bit), a chunk count that doesn't fit in +/// `usize` is clamped to `usize::MAX` rather than silently truncated — +/// `size_hint()`/`len()` then under-report, but never wrap to a smaller, +/// wrong value. +/// /// Constructed by [`BStackChunk::iter`] or `IntoIterator`. pub struct BStackChunkIter<'a> { remaining: BStackSlice<'a>, @@ -547,7 +554,7 @@ impl<'a> Iterator for BStackChunkIter<'a> { #[inline] fn size_hint(&self) -> (usize, Option) { - let n = (self.remaining.len() / self.chunk_len) as usize; + let n = (self.remaining.len() / self.chunk_len).min(usize::MAX as u64) as usize; (n, Some(n)) } } diff --git a/src/test.rs b/src/test.rs index e4fab42..8b3687a 100644 --- a/src/test.rs +++ b/src/test.rs @@ -3812,6 +3812,26 @@ mod alloc_tests { let (second_view, _) = second.as_slice().chunks(2); assert!(first_view < second_view); // same stride, earlier offset sorts first } + + // 22. size_hint()/len() must clamp a u64 chunk count to usize::MAX rather + // than silently truncating it when usize is narrower than u64 (32-bit + // targets). The slice is constructed with a coordinate far beyond the + // real (tiny) backing file — safe because no I/O is performed here, + // only arithmetic on the coordinate. + #[test] + fn chunk_iter_size_hint_clamps_to_usize_max() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(0).unwrap(); + let huge_len = (usize::MAX as u64).saturating_add(2); + let huge_slice = unsafe { BStackSlice::from_raw_parts(alloc.stack(), s.start(), huge_len) }; + let (view, _rem) = huge_slice.chunks(1); + // The true chunk count (u64, unclamped) can exceed usize::MAX. + assert!(view.chunk_count() >= usize::MAX as u64); + let iter = view.iter(); + assert_eq!(iter.size_hint(), (usize::MAX, Some(usize::MAX))); + assert_eq!(iter.len(), usize::MAX); + } } // ------------------------------------------------------------------------- From eba8cecacec9a7908c3bdee0566284381757300a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 21:08:19 -0700 Subject: [PATCH 8/8] Fix allocation in loop --- src/alloc/chunk.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/alloc/chunk.rs b/src/alloc/chunk.rs index 17f6244..af69867 100644 --- a/src/alloc/chunk.rs +++ b/src/alloc/chunk.rs @@ -233,7 +233,9 @@ impl<'a> BStackChunk<'a> { /// `[T]::binary_search_by`. /// /// Reads only the probed chunks — O(log n) chunk reads, never the whole - /// region. No feature flag beyond `alloc`: read-only. + /// region — into one reused buffer (stack-allocated for `chunk_len <= + /// INLINE_SCRATCH_LEN`), not a fresh allocation per probe. No feature + /// flag beyond `alloc`: read-only. /// /// Returns `Ok(Ok(index))` naming a matching chunk, or `Ok(Err(index))` /// with the index a matching chunk would need to be inserted at to keep @@ -243,6 +245,16 @@ impl<'a> BStackChunk<'a> { &self, mut cmp: impl FnMut(&[u8]) -> Ordering, ) -> io::Result> { + let chunk_len = self.chunk_len as usize; + let mut inline = [0u8; INLINE_SCRATCH_LEN]; + let mut heap; + let buf: &mut [u8] = if chunk_len <= INLINE_SCRATCH_LEN { + &mut inline[..chunk_len] + } else { + heap = vec![0u8; chunk_len]; + &mut heap[..] + }; + let mut size = self.chunk_count(); let mut left = 0u64; while size > 0 { @@ -251,8 +263,8 @@ impl<'a> BStackChunk<'a> { let chunk = self .get(mid) .expect("binary_search_by: mid computed within bounds"); - let bytes = chunk.read()?; - match cmp(&bytes) { + chunk.read_into(buf)?; + match cmp(buf) { Ordering::Less => { left = mid + 1; size -= half + 1; @@ -387,9 +399,8 @@ fn sort_chunks_by(buf: &mut [u8], chunk_len: usize, cmp: &mut dyn FnMut(&[u8], & } /// Chunk-sized scratch buffers up to this many bytes live on the stack; -/// larger ones fall back to a heap allocation. Covers the common case (short -/// fixed-width records) without a per-call allocation. -#[cfg(all(feature = "set", feature = "atomic"))] +/// larger ones fall back to a single heap allocation. Covers the common case +/// (short fixed-width records) without allocating at all. const INLINE_SCRATCH_LEN: usize = 128; /// Reorder `buf`'s `chunk_len`-byte records so record `order[dest]` ends up