Skip to content

Add Str.split_at_utf8_byte#1

Closed
ESRogs wants to merge 5 commits into
json-scalar-tailfrom
split-at-utf8-byte
Closed

Add Str.split_at_utf8_byte#1
ESRogs wants to merge 5 commits into
json-scalar-tailfrom
split-at-utf8-byte

Conversation

@ESRogs

@ESRogs ESRogs commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Adds a public Str.split_at_utf8_byte:

split_at_utf8_byte : Str, U64 -> Try({ before : Str, after : Str }, [OutOfBounds, NotACharacterBoundary])

Splits a Str at a byte offset into zero-copy before/after slices. The offset counts UTF-8 bytes (as in Str.count_utf8_bytes) and must be at most the string's length and on a character boundary, so a valid split always yields two valid Strs. The backing intrinsic (str_split_at_utf8_byte_raw, following the str_find_first_raw convention) performs both checks in the shared Zig runtime and returns a flags record — is_out_of_bounds and is_not_char_boundary, each naming one failure, both false on success — that the wrapper maps into Try. No unchecked operation is expressible from Roc.

The JSON string scanner (from roc-lang#10045) uses it to split at the closing quote instead of reconstructing slices via from_utf8 + drop_prefix: O(1) in the string body instead of O(offset).

Performance (interleaved LLVM benchmarks vs the parent commit; each cell is the median wall time for a loop of 20,000 Json.parse calls, default --opt=speed, macOS arm64):

document parent this PR
500 short string fields 1.43 s 1.37 s
3 × 4.9 KB string fields 0.17 s 0.15 s
500 escape-bearing fields 1.54 s 1.48 s
500 numeric fields (only the short keys hit the scanner) 1.50 s 1.49 s

Two measured boundaries on where the win applies:

  • Backend: on the dev backend (no inlining) the intrinsic call is a small per-string overhead instead; the win applies to optimized builds.
  • Split-body size: on an earlier compiler, swapping the intrinsic into the JSON scalar splitter (1-3 byte bodies) measured ~7% slower than pure-Roc slice reconstruction; re-measured on current main, the two are at parity within measurement noise (overlapping spreads, medians 1.53s vs 1.45s on the numeric document across interleaved runs). The win still only appears when the split lands deep inside a long body, since what the intrinsic eliminates is an O(offset) revalidation walk — so this PR keeps it scoped to the string scanner, where bodies are unbounded, and leaves the scalar splitter's pure-Roc slices alone.

Open questions for iteration:

  • Name: settled on split_at_utf8_byte for the count_utf8_bytes rhyme.
  • Error shape: Try with [OutOfBounds, NotACharacterBoundary] publicly; two named failure flags at the intrinsic layer (a comment on the Zig result struct notes that accumulating more failure modes should trigger a switch to from_utf8-style is_ok + problem code).
  • Whether upstream wants a byte-offset operation on Str at all — to be raised on Zulip #API design with this diff as the concrete proposal.

@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch 2 times, most recently from 4d55387 to f3979b0 Compare July 11, 2026 20:02
@ESRogs
ESRogs changed the base branch from json-string-escape-decoding to json-scalar-tail July 11, 2026 20:02
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from f3979b0 to 937c99f Compare July 11, 2026 20:39
@ESRogs
ESRogs force-pushed the json-scalar-tail branch 2 times, most recently from 19448c0 to 1bef00c Compare July 12, 2026 17:24
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from 937c99f to 4d1b851 Compare July 12, 2026 17:24
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from 1bef00c to b1d55a4 Compare July 12, 2026 17:26
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from 4d1b851 to b1536b2 Compare July 12, 2026 17:26
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from b1d55a4 to d0f0732 Compare July 12, 2026 18:55
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from b1536b2 to ac98fec Compare July 12, 2026 18:55
@ESRogs
ESRogs force-pushed the json-scalar-tail branch 2 times, most recently from f0949e5 to 00ecbe1 Compare July 13, 2026 08:07
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch 2 times, most recently from 8fd9f05 to 8798428 Compare July 13, 2026 08:45
@ESRogs
ESRogs force-pushed the json-scalar-tail branch 2 times, most recently from 50d1a04 to 6b4e69a Compare July 13, 2026 09:00
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch 2 times, most recently from 3179d60 to 7798499 Compare July 13, 2026 09:08
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from 7cd07d9 to 02caaf0 Compare July 13, 2026 20:50
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from 7798499 to 66dc1a7 Compare July 13, 2026 20:50
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from 02caaf0 to 34e1e3b Compare July 13, 2026 23:54
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from 66dc1a7 to f83a27a Compare July 13, 2026 23:56
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from 34e1e3b to f54088b Compare July 13, 2026 23:57
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from f83a27a to e1520e3 Compare July 13, 2026 23:57
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from f54088b to 5c4de8b Compare July 13, 2026 23:58
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from e1520e3 to 35dd19a Compare July 13, 2026 23:58
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from 5c4de8b to cd45a1f Compare July 14, 2026 00:00
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from 35dd19a to 9acd732 Compare July 14, 2026 00:00
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from cd45a1f to 3101675 Compare July 14, 2026 00:08
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from 9acd732 to 2e26eda Compare July 14, 2026 00:08
@ESRogs
ESRogs force-pushed the json-scalar-tail branch from 3101675 to 215f058 Compare July 14, 2026 00:12
@ESRogs
ESRogs force-pushed the split-at-utf8-byte branch from 2e26eda to 448911b Compare July 14, 2026 00:12
ESRogs and others added 5 commits July 13, 2026 17:21
…ntrol bytes

Pin the rejected control range at both ends: raw 0x00 and raw 0x1F in a
string reject the document, not just an interior control byte. Add a
positive test for raw DEL (0x7F) — just past the control range and
valid unescaped — and verify that a raw control byte inside a *skipped*
string also rejects the document.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
split_json_scalar_tail made JSON parsing quadratic — more precisely,
O(num scalars * document size): it ran Str.find_first once per
possible delimiter (seven scans, each running to the first occurrence
of its delimiter or to the end of the document), and it rebuilt
`after` by copying the remaining document into a fresh allocation, up
to seven times per scalar.

Find the first delimiter in a single pass instead
(List.find_first_index with an is_json_scalar_delimiter predicate)
and return both halves as zero-copy slices, keeping the delimiter in
`after`. Parsing becomes linear and scalar splitting allocates
nothing: a loop of 20k parses of a 500-field numeric document, which
previously aborted the no-alloc json-decoder test platform, completes
in ~1.5s (~75µs per parse, LLVM --opt=speed) — the same band as the
equivalent string-valued document (~1.4s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The platform's host aborts on any runtime roc_alloc, but its runtime
documents were all string-valued, and app.roc's hardcoded numeric
parses are evaluated at compile time — so scalar skipping was never
exercised under the allocation trap, and split_json_scalar_tail's
previously allocating implementation went uncaught. Add a skipped
numeric field to the runtime-built document so the trap covers
scalars permanently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Negative-path coverage for scalar parsing against the RFC 8259 grammar:
exact literal spellings (wrong case, truncation, trailing junk), number
reject paths (dangling decimal points, incomplete exponents, lone minus,
leading plus, leading zeros, Infinity, hex, junk after a valid number),
accept paths that had no tests (uppercase and signed exponent markers,
sign+fraction+exponent combined, negative zero per target type), and
scalar tokenization at every delimiter (each JSON whitespace byte, comma,
closing brace, closing bracket, and the empty-scalar reject).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Splits a Str at a byte offset into zero-copy before/after slices:

    split_at_utf8_byte : Str, U64
        -> Try({ before : Str, after : Str }, [OutOfBounds, NotACharacterBoundary])

The offset counts UTF-8 bytes (as in Str.count_utf8_bytes) and must be
at most the string's length and fall on a character boundary — the
start of a code point, or the end of the string — so a valid split
always yields two valid Strs.

The compiler-implemented str_split_at_utf8_byte_raw performs both
checks in the shared Zig runtime and returns a flags record
({ before, after, is_not_char_boundary, is_out_of_bounds } — each
flag names one failure, and both false means success) that the public
wrapper maps into Try, so no unchecked operation is expressible from
Roc. Wired through: LowLevel enum (+ retainsSharingArgs group),
BuiltinLowLevel canonicalization, str.zig runtime, dev_wrappers C-ABI
wrapper and layout struct, both static-lib export tables, the dev
object reader, the dev, LLVM, and wasm backends, the wasm runner host
shim, and the interpreter.

The JSON string scanner now splits at the closing quote with the
intrinsic instead of reconstructing slices via from_utf8 +
drop_prefix, making the split O(1) in the string body instead of
O(offset). Interleaved LLVM benchmarks against the parent commit,
each figure the median wall time for a loop of 20k Json.parse calls
(default --opt=speed, macOS arm64): 500 short string fields
1.43s -> 1.37s, 3 x 4.9KB fields 0.17s -> 0.15s, escape-bearing
fields 1.54s -> 1.48s, numeric fields (only the short keys hit the
scanner) 1.50s -> 1.49s. On the dev backend, which does not inline,
the intrinsic call is a small per-string overhead instead; the win
applies to optimized builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant