ADR-016: The UTF-8 Check Is Divided, Not Skipped

Status: Accepted; D5 added October 19, 2026 (the check moves from fs::map to where the mapping becomes text). Closes: UTF-8 validation being serial, the next cost after ADR-015 Related: ADR-283 (& is a view marker — which is why D1 is not optional), ADR-296 D19 (nothing but the driver), ADR-296 D10 (chunk count does not change results)

1. Context

fs::map validates the file as UTF-8 before the parser sees it, serially. At 105 MB that is about a third of the parallel run; at the benchmark’s real 13 GB it would be seconds before a row is read. Two fair questions follow: why is the check there at all, and why is it that slow?

2. Decisions

D1 — The check stays, because it is what makes a view a &str.

fs::map hands the parser a &str over the mapped pages, and every slice the grammar cuts out of it is a &str into those same pages. Removing the check does not make the program slightly less careful; it makes it from_utf8_unchecked over bytes nobody controls, and every view after it undefined behaviour in code the user wrote safely.

Not an argument from principle. With the check removed, the flagship prints

{Hamburg=12.0/12.0/12.0, \xffBad=3.0/3.0/3.0}

for a file with one stray byte — a station name that is a &str, is not UTF-8, and is formatted without complaint on the far side of a Deref that promises otherwise. The check is load-bearing.

What may eventually skip it is an assertion the user makes, in the shape the language already has three times over (@frame(…, unchecked), fs::map(…; trusted: false), dsl backend::x86) — a hatch the source opens and the ledger records, never a default the runtime picks. §4 says why there is none.

D2 — The check is chunked at character boundaries, and reports the earliest failure.

fs::map cuts the mapping into one piece per core and validates in parallel. A cut walks forward off a continuation byte, at most three times, so every piece begins where a character begins: no sequence is split, and no piece can accept a truncation the piece before it left behind. A failing piece contributes chunk_start + valid_up_to, and the answer is the minimum — so a rejected file names the same byte however many cores looked at it, which is ADR-296 D10’s promise applied to validation.

Below 1 MiB it runs in one piece: waking the pool costs more than the check.

The serial validator is the oracle it must agree with, verdict and offset.

D3 — user_parallelism does not reach into std, and does not here either.

The obvious objection: user_parallelism = no means Parallelism::Off, so should fs::map validate on one core there? No — and it could not, because ADR-296 D19 says nothing but the DSL driver reads the switch.

What the switch governs is the fold: whether the input is cut into pieces whose results are merged. The UTF-8 check has no result to merge. It returns the same verdict and the same offset on one core and on sixty-four (D2), so how many cores ran it is not observable in the program — it is a library function doing the fastest correct thing.

The switch bounds the user’s code, not every function a program calls (ADR-037 D2).

D4 — Each piece is checked with SIMD, and answers as the standard library does.

checked-text checks a piece with simdutf8: its fast check answers the common case, that the bytes are text, and only a piece that fails is checked again by its compat half, which finds the offset std::str::from_utf8 would. The verdict and the offset are the serial oracle’s (D2); a test holds one piece to it for every one- and two-byte sequence and a set of three- and four-byte ones, each at the start of a piece, inside a long ASCII run and across the 64-byte blocks the vector check reads.

It was rejected while the check was 10 ms of a 140 ms program. On 1BRC since (benches/brc, Nikaia 0.0.424), it was 35.5 of 390 instructions a row and 0.89 of its 4.28 mispredictions, the largest single thing left against the hand-written loop that does not validate at all; with simdutf8 the program runs 365 a row and 3.42, the same output and the same refusal of a stray byte, at the same offset. On the clock, pinned to one core and unpinned alike, it is the same or faster (0.332 → 0.305 s, 0.305 → 0.278 s); on an all-ASCII twin of the file it is the same (0.301 → 0.302 s), which is the case where the standard library’s own fast path was already good. The dependency is one crate, behind checked-text’s simd feature, so the crate that holds the unsafe of text still has none without its features.

D5 — The check is made where the bytes become text, not where the file is mapped.

fs::map hands back the pages as a Bytes and checks nothing. m.text() is the mapping as text: the first call checks the whole of it as D2-D4 say and throws io::IoError::NotText with the earliest bad byte; every later call hands back the same view without checking again. A cut, m[a..<b].text(), checks that cut and nothing else, as any Bytes’s does (ADR-320 D7). A grammar run over a mapping checks the text it reads, and a byte that is not UTF-8 is an error at its position.

D1 is untouched: nothing becomes a &str unchecked. What moves is where the check is paid. A program that reads only some of a file as text, a binary format with text fields, or a parser that needs a few names out of a gigabyte, pays for the bytes it reads as text and not for the file. A program that reads all of it as text pays what it paid before, once. And the fused check §4 deferred, each piece checked on the thread that parses it, becomes the grammar’s to do, because the grammar is handed bytes.

The price is one line: a program that reads a mapping as text writes m.text(), and NotText comes from there instead of from fs::map.

3. The measurement

On 8 million rows (105 MB, about 1.5 % of bytes multi-byte) and 4 cores, the chunked check divides by the core count (about 3.9×), and what is left to gain by removing the check entirely is 10 ms — 2 % sequential, 7 % parallel — against the output in D1. That is the answer to “why not switch it off”.

The check costs a larger share of the time than of the instructions because it is a second pass over the memory, which the parse then walks again. And it is slow on this data because the standard validator’s ASCII fast path drops to the per-character path at every multi-byte station name — about 10 GB/s on an ASCII twin of the file, under 2 GB/s on the real one.

4. Rejected and deferred

This site is open source. Improve this page.