Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
williamwutq marked this conversation as resolved.
- **`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

Expand Down
37 changes: 15 additions & 22 deletions PLANNED.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,39 +131,32 @@ The mutex's scope and implementation are not in question here (see the `NOT PLAN

- **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
## External-merge-sort strategy and partial sort for `BStackChunk`

**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.
**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

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.
`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<u8>` 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

- **`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.
- **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.

- **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.
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.

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.
- **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.

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.
- **`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.

**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<u64, u64>` 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.
- **`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 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.
- **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.
Loading
Loading