Issue #453: What a program needs to tune a text loop by hand: a byte search from a position, and reading text a word at a time (decision basis owed)

Open and answer this issue on GitHub.

language, libraries, performance, ready · opened 2026-10-05 · updated 2026-10-19 · 13 comments

The question

Can a Nikaia program make a hot loop over text as fast as tuned Rust, when the author is willing to write more? Today the answer is no. The fastest Nikaia 1BRC is the one that writes the least: the grammar in examples/1brc.nika. Every hand-tuned variant was worse, because two building blocks are missing.

What was measured

Callgrind, 1 M rows, per row, the same output as naive in every case. The method is benches/brc/README.md’s. The compiler is 0.0.478.

variant instructions mispredictions
examples/1brc.nika: grammar and HashMap 300.1 1.58
grammar, own open-addressing table, FNV-1a over every byte of the name 330.5 2.66
grammar, own table, hash of the length and the first 8 bytes at most 309.3 1.49
one hand-written loop over text.bytes(), hash computed while reading the name, own table 428.0 4.39
Rust, tuned 272.2 3.70

Where it goes:

What is missing

  1. A search for a byte from a position: something like text.find(b';', from) → i64?, or an iterator of positions, backed by memchr.
  2. Reading text a word at a time, or a hash of a text slice the program can call: hash::of(name) with std’s byte hash, so a table of one’s own does not pay a byte loop.
  3. Possibly byte access by position (text.byte_at(i)), which a tuned parser walks back with.

What a decision has to say

The decision basis (introduction, today’s state, other languages, options, a recommendation) is owed before this is asked.

Comments

keywan-ghadami, 2026-10-05

Decision (owner, 2026-10-05)

Written in 9d7ddc11 (0.0.496):

How it came to this

The question started as what does a hand-tuned text loop need, measured above. In the round with the owner it became what one character is in the language as a whole:

  1. A search from a position, and a Bytes that can be read, were agreed first.
  2. The owner rejected it waits for a program that needs it as a reason to leave a capability out. ADR-288 D11 was clarified in 0.0.485.
  3. Positions were checked on today’s compiler: s.len() counts bytes, s[a..<b] cuts at bytes and stops in Rust’s words inside a scalar, and the specification said none of it.
  4. Swift’s model was weighed: views, String.Index, graphemes checked in the compiler and segmented again in the runtime. The owner chose views that name their unit, with graphemes kept out of the compiler (Rust’s and Go’s split).
  5. In the owner’s review the following were sharpened:
    • surrogates excluded, so the type is scalar;
    • brackets only where they cost nothing, so s.scalars and s.graphemes are sequences;
    • a decomposed 'é' is normalised with a warning rather than refused;
    • == and a hash compare bytes, and normalising is written with its form.

In short

Still open: a hash a program can call.

To build

The order matters: each step keeps the tree building.

  1. scalar as a type beside char. Make char an alias the checker accepts for now, then move std.contracts, text::digit_value, the grammar builtins (any, digit, hex_digit, char) and the self-hosted tools to scalar. After that, char is NK1135 with the help naming scalar and u8.
  2. Literals: a '…' typed by its use as u8 or scalar, as a number is (Part I 2.4), defaulting to scalar; '\xNN' above \x7F only as a u8; surrogates refused; NK1212. NK1213 normalises through std’s NFC.
  3. Views: s.bytes (index, cut, len, find, for), s.scalars and s.graphemes (take, skip, count, for). Graphemes go through std, e.g. unicode-segmentation behind nikaia-std, with its Unicode version stated.
  4. find: s.find(needle; from: i64 = 0) -> i64? on text, with a one-byte needle through memchr; b.find(byte; from:) on Bytes and s.bytes. This replaces the fall-through to Rust’s str::find (#452).
  5. Bytes: b[i] -> u8, b[a..<b], b.find, b.text() (#452’s raw[0]).
  6. Positions: the checker keeps where a position came from (find, a grammar match, plus a literal’s byte length) and lets it cut that text. A made-up number or another text’s position is NK1214, with the view in the help. A cut inside a scalar stops the program with the language’s own message, not Rust’s not a char boundary.
  7. Remove s.len() and s[n] on text. Move the self-hosted tools off chars().collect() + index, onto views or find.
  8. s.normalized(; form:) in std.
  9. Tests:
    • each row of Part I 2.6’s literal rules;
    • '\u{D800}' refused;
    • a decomposed 'é' taken with NK1213;
    • line[..<sep] after find compiles, and s[0..<10] is NK1214;
    • a cut inside ä stops with the language’s message;
    • "ä" == "a\u{308}" false, and true after normalized(; form: nfc);
    • s.graphemes.count() of a family emoji is 1;
    • find from inside a scalar finds the next match;
    • 1BRC by hand with find measured again against the table above.

Generated by Claude Code

keywan-ghadami, 2026-10-06

Added to step 5 (Bytes), from #489 (closed as a duplicate of this issue):


Generated by Claude Code

keywan-ghadami, 2026-10-07

Step 4 built for text (329fd8e, 0.0.635)

s.find(needle; from: i64 = 0) -> i64? gives the byte position of the first match at or after from, or null.

How it is built

What it does

Tests

Not covered by this step


Generated by Claude Code

keywan-ghadami, 2026-10-07

Step 5 built (8e375e3, 0.0.637), and #527 (b00f52d, 0.0.636)

Step 5: reading a Bytes

Tests

Not covered: invalid UTF-8 over a socket. A program cannot write raw bytes to a socket yet, so this is shown with a file; it is the same Bytes::text.

Note: a byte is written as a number for now (b.find(59)). ';' as a u8 is step 2.

#527: a cut with an open end

[..<b], [..b] and [a..] now parse. [a..] is lowered to the language below’s a.., so the base is read once. Part I 2.6’s example after find now parses.

What remains


Generated by Claude Code

keywan-ghadami, 2026-10-08

Step 1 is finished; step 2’s first half is built (0.0.659 8ff69c9, 0.0.660 9127dae)

Step 1: there is no char.

Step 2, first half: a '…' is a u8 where a byte is wanted.

Still open in step 2:

Two things the records do not say, before steps 3 and 8 can be built:

  1. What type s.bytes has. It indexes, cuts, has len/find, is walked by for, and s.bytes[0..<10].text() makes text again. A copy into a Bytes would allocate on every use, which defeats #453’s own measurement. So it needs a view type of its own, a borrowed run of the text’s bytes. Its name decides what a program writes in an annotation (let b: ? = s.bytes) and in a parameter.
  2. What nfc is in s.normalized(; form: nfc). Every other variant in the specification is written in full (supervisor::Strategy::OneForOne, fs::Root::Anywhere). A bare nfc needs either a rule that a variant may be written bare where its type is known, or the form written as text::Form::Nfc.

Generated by Claude Code

keywan-ghadami, 2026-10-18

Decided (owner, 2026-10-18): the two open questions from the last comment

Written in 5fdce58 (0.0.1030): ADR-320 D10 (amended) and D11, and Part I 2.6.

  1. s.bytes is a Bytes. It is the type fs::read and a socket hand out, so there is one set of reads and one parameter type. Like a String (ADR-282 D2), it is a view wherever a view suffices: borrowed from the text’s buffer, with no copy unless it is kept beyond the text. Rejected: a view type of its own (Swift’s UTF8View), a copy every time (Go).
  2. s.normalized(text::Form::Nfc) (or Nfd, Nfkc, Nfkd). The form is a positional argument, not an option after ;, because no form is a sensible default. As a variant, it is written with its full path. The owner’s proposal; rejected: s.nfc() (the form cannot be a value), a bare nfc (a rule for every enum).

Changes to the To-build list


Generated by Claude Code

keywan-ghadami, 2026-10-18

Step 3, part one is built: 620afac, version 0.0.1031 (92a2151).

Still missing for step 3:

  1. The copy is only made where s.bytes is written directly. let k = s.bytes followed by return k, or pushing it into a list, still fails in rustc with “mismatched types”. The copy has to follow the value: wherever a let bound to s.bytes escapes, it gets Bytes::from(..), as an escaping String view does. Test: return through a let, push into a Vec[Bytes], store through a let into a field.
  2. Bytes has no .clone() contract, so a program cannot copy explicitly. Add it, with a test.

Generated by Claude Code

keywan-ghadami, 2026-10-18

Step 3’s s.bytes part is built: 2089126, version 0.0.1032 (6341d31). s.scalars and s.graphemes from step 3 are not built yet.

Found on the way:


Generated by Claude Code

keywan-ghadami, 2026-10-18

Step 3 is finished: e08b9e5, version 0.0.1033 (ac754ce).

What remains:


Generated by Claude Code

keywan-ghadami, 2026-10-18

Step 8 and the NFC half of step 2 are built in 13c70fe, version 0.0.1034 (14b72d3).

Still open:


Generated by Claude Code

keywan-ghadami, 2026-10-18

Step 2 is built in 5fdee1e, version 0.0.1035 (38111d4). The rule is in Part I 2.6 and in Part III’s NK1200 entry.

Still open, small:

Remaining steps: 6 (positions, NK1214), 7 (no s.len() / s[n] on text) and 9 (the remaining tests, and 1BRC by hand measured again).


Generated by Claude Code

keywan-ghadami, 2026-10-18

Step 6 is built in ff7f50c (version 0.0.1037, e77d11f; the commit subject says 0.0.1036, which #659 had taken in the meantime).

What counts as a position of a text (check_core_places_text_position, kept per immutable let in CheckState.text_positions):

Refused with NK1214:

The help names s.bytes[a..<b].text() and s.graphemes.take(n). Cuts of s.bytes and of Bytes take any number.

A cut inside a scalar stops the program with the language’s own words, through TextCut in nikaia-std/src/index.rs: “byte position N is inside a scalar of the text: cut it at a position it handed out, or through .bytes”.

Fixed on the way: in a for over a Bytes, the loop variable had no type, so c == '\n' was refused in rustc. It is a u8 now, with a test in text_bytes.rs.

Moved to the new rule:

Tests: text_positions (7), plus the text, slice and indexing tests, and tests/build-time 41/41.

In progress: crates/rs2nika (about 157 cuts) is being moved off made-up cuts. Until that lands, the rs2nika CI job is red whenever it runs.

Still open:


Generated by Claude Code

keywan-ghadami, 2026-10-19

For a table of one’s own: #434 measured what each variant of a hash table costs, and for which data. The variants are a stored hash per entry, keys compared a word at a time, and no removal. The defaults stay as they are because the compiler cannot know the data. Whoever tunes by hand does know it, and needs exactly these means: a hash of a text slice the program can call (point 2 above), a hasher chosen per map (#581), and a Map trait a table of one’s own can implement (#593). The numbers are in the closing comment of #434.


Generated by Claude Code

Back to Open Issues