Bytes Searches for a Set of Bytes by What the Program Wants, Not by How Wide It LooksStatus: Accepted; D5 (a search’s positions are facts) added October 20, 2026; D6 (how find_all goes from one hit to the next) added October 21, 2026 (the first part of the API record ADR-371 D4 asks for: searching whole buffers; vectors a program holds are decided after it)
Date: October 20, 2026
Answers: how a program finds the bytes of a small set in a buffer as fast as
the machine it is built for allows, without naming a vector width
Related: ADR-371 (vector lanes are std’s), ADR-002
D6 (the CPU level is the build command’s), ADR-320 D6 (find
from a position), D7 (a Bytes is read as a run of bytes), D13 (no read past
the end), ADR-374 (a position in text), Part III’s Seq
let seps = bytes::ByteSet([';', '\n'])
for p in data.find_all(seps) { … }
The fastest programs over a large input compare 16 to 64 bytes in one instruction. A width written in the source is wrong on every machine whose registers are of another width, and most of what such programs do - find the bytes of a set, count them, cut at them - gives the same answer at any width. The width belongs to the build (ADR-002 D6); the program says what it is looking for.
std searches a buffer for the bytes of a set at the width the build’s CPU
level makes best, handles the end of the buffer itself, and gives the same
result at every width. No name says SIMD or parallel: how the search runs is
the build’s, and parallel is the word for threads (par_fold, par_iter).
Why. On 1BRC the step that mattered was not the width: replacing a call per row with a scan over many rows moved a hand-written Rust program by 64 to 85 instructions a row, and 32 lanes against 16 by about 8. A search over the whole buffer takes that gain without a vector in the program, and is right on a machine of any width, SVE’s and RISC-V V’s included, whose width is known only on the machine.
bytes::ByteSet, built once, of any bytes.bytes::ByteSet([';', '\n']) is a set of bytes, 0x00 to 0xFF, prepared
once for the search it will serve. It may hold any number of bytes. A set the
build proves empty (ByteSet([])) is refused (NK1287); one that is empty at
run time is a set like any other, in which nothing is found: find is null,
find_all yields nothing, split yields the whole buffer as its one piece.
Why. The preparation - a few comparisons, or two lookup tables over the low
and the high four bits for a large set - is done once instead of at every call
in a loop, as .NET’s SearchValues and Rust’s memchr::memmem::Finder are.
A Bytes holds no scalars, and the formats it is read for search for bytes
above 0x7F (0xFF markers, length and type bytes), which the same
comparisons find at no other cost. A set written empty is a mistake the build
can show; one that a program computes may be empty for good reasons, and “found
nothing” is already what each search says. Rejected: ASCII only, which matters
for text, where a byte at 0x80 or above may lie inside a scalar, and is
decided with text’s search; a set that is null where it is empty, which asks
for a ?? even after a set of literals; a set rebuilt from a list at every
call.
find gives the first, find_all every one, and split the pieces between.data.find(seps; from: i) // i64? the first at or after i
data.find_all(seps; from: i) // Seq[i64] every one, in order
data.split(seps) // Seq[ref(data) Bytes] the pieces between
find takes a u8, as it does today, or a ByteSet. find_all yields the
positions of every byte of the set at or after from, rising. split yields
the pieces between them, each a view of data, by text’s split rules: two
neighbouring bytes of the set give an empty piece between them, one at the end
an empty last piece, and an empty data one empty piece. Which byte a position
holds is data[p].
Why. One name per thing a search is for, each with costs of its own: the
first stops at it, all of them walks the buffer, and the pieces are what a
reader of fields wants without keeping a start by hand. split is the one text
already has; a Bytes reads as text does where it can (ADR-320 D7). Rejected:
a list (Vec[i64]), which costs eight bytes a hit where most programs read each
once - collect makes one; an Array whose length follows the block or is
fixed by the call, which puts a width back into the program; pairs of position
and byte, which every consumer takes apart; pieces that carry the byte they
ended at; a name for each count of results (find_first_of, count_of,
contains_any). Counting is find_all(seps).count() (D4); whether it gets a
name of its own, and whether rfind takes a set, is not decided here.
std producer is the one the ledger names.A Seq is not in a program’s type grammar, so at every consumer the producer is
known. Where the ledger gives a producer a consumer of its own, a consumer
written right after it is that one, never the general walk:
data.find_all(seps).count() counts per block and makes no position, first,
nth and last skip whole blocks. A step between them (filter, map,
take) makes another producer, and the general walk then applies.
Why. The program writes what it wants and the language promises the way std
takes, which is what makes the first level worth having: a count written as a
walk is not a walk. A promise held only by an optimiser is one a reader cannot
rely on; one held by the type is kept at every call. Rust keeps the same kind of
promise for str::Chars::count.
for s in b.find_all(seps; from: f) {
n += b[s] // f <= s < b.len(): no check
}
Each position find_all(set; from: f) yields is a fact for the walk:
f <= s and s < b.len(), where b is the buffer it was called on and no
write to b stands between. std’s ledger states it on the sequence’s
elements, as ensures states it on a result (ADR-314), and the walk reads it
on the name a for binds.
Why. The bound is what the search means; making a program restate it as
an assert only adds a check where none is needed, and an assert that only
the search could prove is checked at every turn. Rust leaves the same index
checked unless LLVM sees the search’s body. Rejected: an assert in the
program; a type of positions that carries its buffer, which is more than one
loop needs. Other searches - find over a set, split’s pieces, text’s
search - take the same entry when a program needs it and an assert cannot
give it; until then they give none.
find_all goes from one hit to the next is the program’s to say.for sep in buf.find_all(bytes::ByteSet([';']); scan: bytes::Scan::AfterHit) { … }
find_all takes scan: bytes::Scan. Scan::ByBlock, the default, keeps the
mask of a block and reads the next block when its hits are spent.
Scan::AfterHit compares the half block that starts right after each hit and
takes its first, and reads whole blocks only where that half holds none. Both
yield the same positions, and D4’s consumers count, skip and find the last by
whole blocks under either.
Why. The two cost differently, and which costs less depends on how far
apart the hits lie, which only the program knows. By block a hit costs a
lowest bit and a cleared one, but the test whether the mask is spent goes one
way a varying number of times and then the other, and is mispredicted about
once a block. After a hit every hit costs a compare, but where the next hit
is mostly within half a block the test goes the same way almost every time
and is predicted. Counted over 1BRC’s rows (one ; in about 14 bytes) after
a hit saves about 0.25 mispredicted branches a row at 2 to 8 instructions
more; over separators every 7 bytes it costs 12 to 42 instructions a row more
for the same saving, so that it loses at the baseline level. Rejected:
after a hit for every search (it makes a dense search up to twice as costly);
std switching between them by the hits it has seen (a threshold fitted to
the programs measured, and a branch on it); a search from a position with
find in the program’s loop (it gives up D5’s facts, which find does not
have, and D4’s consumers).
std gets bytes::ByteSet, Bytes::find over a set, Bytes::find_all and
Bytes::split, and the ledger names the consumers of find_all’s sequence.text::Match for a position; until then it has none.crc32c, prefetch, the first instructions of std::backend::x86
and how a program asks its CPU level: ADR-371 D4, in records after this one.count and rfind over a set, and a set’s complement (not).