Skip to content

draft: Port GetPointerToFirstInvalidByte from SimdUnicode - #974

Draft
BenjaminBucher wants to merge 50 commits into
simdutf:masterfrom
BenjaminBucher:simdunicode-port
Draft

draft: Port GetPointerToFirstInvalidByte from SimdUnicode#974
BenjaminBucher wants to merge 50 commits into
simdutf:masterfrom
BenjaminBucher:simdunicode-port

Conversation

@BenjaminBucher

Copy link
Copy Markdown
Contributor

This PR is a draft, so it is very much WIP but open to feedback

This is a step towards #147 for transcoding UTF8 to UTF16 with replacement.
The new function locates the first invalid byte of UTF8 and also gathers information relevant to transcode to UTF16.

SimdUnicode

The function is already implemented for some platforms in C# over at SimdUnicode.
In this PR I port that function to simdutf, called GetPointerToFirstInvalidByte in SimdUnicode.

In SimdUnicode, the function returns the pointer to the next invalid byte of UTF8, the number of utf16 code units and the amount of characters/code points in total.
The number of utf16 units is required to calculate the output length.
Does the amount of total characters help in any way? I could imagine some niche optimization relying on number of utf16 units == number of total characters but am unsure if this is a thing.

Use in Transcoding UTF8 to UTF16

Transcoding UTF8 to UTF16 could be done in 2 passes using this function.
Note that the idea is to assume little to no encoding errors. We determine a reasonable threshold N beforehand for how many errors we expect at most.
If the number of actual errors is higher, the transcoding will be significantly slower as it will have to once again have to scan for errors on the second pass.

Step 1: Determine UTF16 length and error locations:

  • Determine input slices of valid and invalid UTF8
  • Determine output UTF16 length (with replacement logic)
  • Store info for up to N valid ranges

Step 1.5: User needs to find / allocate sufficient space.
Step 2: Transcoding with replacement

  • Handle stored valid slices with existing transcoding functions, invalid slices with additional logic
  • In the case more than N errors, the valid/invalid slices must be rediscovered on the fly

Open questions

  • Name of the function
  • Should this function even appear in the public API
  • What extra info should the function return?
    [x] Amount of UTF16 units
    [ ] Amount of characters
    [ ] Correct UTF8 error code
    [ ] More?

I'm now hopping into porting the actual SIMD part of the code, wish me luck :)

Comment thread validate_utf8_for_utf16_transcoding.sig Outdated
@lemire
lemire marked this pull request as draft May 12, 2026 22:40
@lemire

lemire commented May 12, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher

Thanks for the PR. What about making the function more generic ?

Here is a gist (not a proposal)...

struct utf8_segment {
  size_t length; // length in bytes
  size_t ascii; // number of ASCII characters
  size_t two_bytes; // number of two-byte (UTF-8) characters
  size_t three_bytes; // number of three-byte (UTF-8) characters
  size_t four_bytes; // number of four-byte (UTF-8) characters
}

And then we have something like...

utf8_segment  find_first_invalid_utf8(const char * input, size_t length);

This would be a great building block that could be used for various applications, not just UTF-8 to UTF-16 transcoding.

cc @pauldreik @anonrig @erikcorry

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

I'm definitely in favor of making this function more generic.
The concrete error code could also be returned by the function, as it is quite cheap to compute if we already compute the error location (and might also be helpful).

@lemire

lemire commented May 20, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher Yeah.

What we want is the common case to be fast (which is going to be proper UTF-8).

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

I'm highly confused by the current compile error:

In file included from /src/src/haswell/implementation.cpp:103,
                 from /src/src/simdutf.cpp:133:
/src/src/generic/ascii_validation.h: In function 'simdutf::result simdutf::simdutf::haswell::{anonymous}::ascii_validation::generic_validate_ascii_with_errors(const char*, size_t)':
/src/src/generic/ascii_validation.h:7:3: error: 'buf_block_reader' was not declared in this scope; did you mean 'simdutf::haswell::{anonymous}::buf_block_reader'?
    7 |   buf_block_reader<64> reader(reinterpret_cast<const uint8_t *>(input), length);
      |   ^~~~~~~~~~~~~~~~
      |   simdutf::haswell::{anonymous}::buf_block_reader

It complains about buf_block_reader not being available in ascii_validation.h.
I do not understand which of my changes might've broken the compilation of that file. buf_block_reader should be perfectly available and is used in the same way in utf8_validator.h, which is a file I did modify.

@pauldreik

Copy link
Copy Markdown
Collaborator

I'm highly confused by the current compile error:

In file included from /src/src/haswell/implementation.cpp:103,
                 from /src/src/simdutf.cpp:133:
/src/src/generic/ascii_validation.h: In function 'simdutf::result simdutf::simdutf::haswell::{anonymous}::ascii_validation::generic_validate_ascii_with_errors(const char*, size_t)':
/src/src/generic/ascii_validation.h:7:3: error: 'buf_block_reader' was not declared in this scope; did you mean 'simdutf::haswell::{anonymous}::buf_block_reader'?
    7 |   buf_block_reader<64> reader(reinterpret_cast<const uint8_t *>(input), length);
      |   ^~~~~~~~~~~~~~~~
      |   simdutf::haswell::{anonymous}::buf_block_reader

It complains about buf_block_reader not being available in ascii_validation.h. I do not understand which of my changes might've broken the compilation of that file. buf_block_reader should be perfectly available and is used in the same way in utf8_validator.h, which is a file I did modify.

sometimes it is helpful to do an amalgamation, and work with the amalgamated source. the include system can be very confusing.

Comment thread src/generic/utf8_validation/utf8_validator.h
Comment thread src/generic/utf8_validation/utf8_lookup4_algorithm.h
@lemire

lemire commented May 23, 2026

Copy link
Copy Markdown
Member

@pauldreik

sometimes it is helpful to do an amalgamation, and work with the amalgamated source. the include system can be very confusing.

Ah Ah

I think it is quite simple and a classic: a missing final brace (}) which makes the C++ compiler go wild and report strange errors.

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

@pauldreik Yes, the include system did confuse me a couple of times already, but I'm slowly getting a feel for it. Thanks for the tip with the amalgamation.
I also kind of gave up using an IDE for now an am just working with the compiler messages the classic way :)

Comment thread include/simdutf/scalar/utf8.h Outdated
@pauldreik

Copy link
Copy Markdown
Collaborator

another tip for detecting silly mistakes like a missing brace is to run the clang format script: you often get large changes in the wrong place.

see scripts/clang_format.sh

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

@lemire
I fear that the linear system of equations to calculate number of ascii symbols and 2-/3-byte leads with the current set of counters is underdetermined.

We got: length (amount of bytes), ncon (amount of continuation bytes), n4 (amount of 4-byte leads)
We want: n1, n2, n3 (amount of ascii bytes, 2-/3-byte leads)

length = n4 * 4 + n3 * 3 + n2 * 2 + n1
ncon = n4 * 3 + n3 * 2 + n2

If I didn't miss anything, we only have 2 equations.
To calculate the utf16 length we can luckily do without solving the linear equation.
However, as we have fewer equations than unknowns, we can't provide all these numbers with just keeping track of the amount of continuation bytes and 4-byte leads.

We'd need to keep track of another variable, which we can do, but will add more runtime overhead not required for utf16 length.
This might not be too bad, but a relevant decision, which is why I want input :)

@lemire

lemire commented Jun 2, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher You are, of course, correct.

I think we need at least one more piece of information if we want to proceed with my sketch of an idea: the total number of ASCII bytes ($n_1$).

Let (L) be the total number of bytes in the string. We define:

  • $n_1$: number of 1-byte characters (ASCII)
  • $n_2$: number of 2-byte characters
  • $n_3$: number of 3-byte characters
  • $n_4$: number of 4-byte characters
  • $n_{\rm con}$: number of continuation bytes

We have two expressions for the total byte length $L$:

$$ L = 4n_4 + 3n_3 + 2n_2 + n_1 $$

$$ L = n_4 + n_3 + n_2 + n_{\rm con} + n_1 $$

Solving the linear system for $n_3$ and $n_2$:

$$ n_3 = n_1 - 2n_4 + 2n_{\rm con} - L $$

$$ n_2 = -2n_1 + n_4 - 3n_{\rm con} + 2L $$

You might object that this will entail extra processing. It is a slight additional cost.

Right.

So maybe we can do something else instead.

What about this:

struct utf8_segment {
  size_t length; // length in bytes
  size_t characters; // number of characters ASCII, two-byte, three-byte and 4-byte
  size_t four_bytes; // number of four-byte (UTF-8) characters
}

The number of characters is:

$$ N = n_1 + n_2 + n_3 + n_4 = L - n_{\rm con} $$

That's it. Characters = bytes minus continuation bytes.

This generic data structure is enough to compute the required UTF-16 output (it is just characters + four-bytes). But also the required UTF-32 output (it is just characters).

The benefit of my approach, I think, it is that it is easier to debug.

The downside is that the data structure is slightly fatter than what is needed, if we just want to go to UTF-16, then we have slightly too much information. So there is a tiny performance penalty here, with my proposal, but I think that it is so small as to be irrelevant in practice.

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

With the focus on throughput, I first definitely recoiled from the idea of keeping yet another counter.
However, I am easily convinced that the actual impact can be negligible.

Keeping track of ascii/non-ascii characters sounds simple enough.

I'll adjust the generic implementation. In general, it could be slightly improved by duplicating code more code and integrating the counters deeper in the simd functions.
Next I'll take a look at the avx implementation :)

@lemire

lemire commented Jun 3, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher I am not arguing that we need to keep track of the ASCII characters. It may indeed be quite cheap.

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

Fair fair, that sounds to me like a good solution :)

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

Among other things the compilers appear to not particularly like the tuples. They aren't that great anyways and should probably be replaced with a struct. Can I define the struct somewhere where it won't appear in the public API?

@lemire

lemire commented Jun 25, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher I think you are doing fine, the errors look like details we can work out later.

This being said, I agree that a dedicated struct is better. Why not make it public? There is nothing wrong with public structures?

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

Fair point. I'm typically in favor of keeping the public interface minimal, and I wasn't convinced that the benefits of a struct for this are sufficient :)

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

The icelake implementation is embarrassingly simple and I want to propose to do the same in the generic implementation.
In the generic implementation I tried to stay close to the simdunicode implementation and because of that dug relatively deep into the simd validation code. I still tried to keep the validation logic somewhat separate to counts, in order to keep the logic simple and easier to verify.

For the icelake implementation I didn't check the simdunicode implementation and found it significantly easier to keep the counts out of the validator and instead do the counts where the reader is processed, chunk for chunk.

Trade-offs (non-exhaustive):

  • Doing the counts alongside the validation logic would save ~2 avx-512 comparison operations, maybe another load but for that I'd need to look into the assembly. However, this would require us to duplicate the entire validation code and changing it in a few places with more state, which would make the code more complex.
  • In the generic code, one reader chunk contains either 2 or 4 full simd registers worth of data. The count logic is currently inserted in a location where each of the simd register parts of the chunk is loaded only once. (Once again, at the cost of more code complexity).

I don't have good intuition for where overoptimization starts and where good-enough and simple code is in order. Feedback wanted :)

@lemire

lemire commented Jul 7, 2026

Copy link
Copy Markdown
Member

I’ll get back to you in the next few days.

@lemire

lemire commented Jul 8, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher Sorry for the delay.

Ok. At this stage, we don't care about performance very much, at least not down to the details of each implementation. What we care a great deal is to get a correct results.

@lemire

lemire commented Jul 8, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher The AVX-512 is not quite correct, see my comment. Once this is fixed, then I expect your code to be correct.

The next step would then be to wire in this function into our benchmarks, and, then, once we have benchmarks, it is easier to take decisions regarding the code.

As a rule, have slightly more complicated code for better performance is a good tradeoff in simdutf. Our main selling point is performance, not code simplicity.

@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

I can hardly call a single day a delay ^^.
Where is your comment on the avx512 code, I'm not sure whether it was sent.
I'll take a look at benchmarking.

Comment thread validate_utf8_with_counts.sig Outdated
Comment thread src/icelake/implementation.cpp Outdated
non_ascii += utf8_count_non_ascii(utf8);
count += 64;
}
const __m512i utf8 = _mm512_maskz_loadu_epi8(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BenjaminBucher

If end == ptr, we have ~UINT64_C(0) >> 64 which compiles as ~UINT64_C(0) >> 0 (it is undefined).

So this is not safe.

Because you branch on end!=ptr anyhow, you should guard this code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The control flow is a little odd there, as we needed that utf8 segment in case there's no error later on to continue the counters. Switched it up to update a second set of counters so we have either for the different code paths.

Comment thread src/generic/utf8_validation/utf8_validator.h Outdated
Comment thread include/simdutf/scalar/utf8.h Outdated
Comment thread include/simdutf/scalar/utf8.h Outdated
Comment thread include/simdutf/scalar/utf8.h Outdated
Comment thread include/simdutf/scalar/utf8.h Outdated
Comment thread include/simdutf/scalar/utf8.h
Comment thread include/simdutf/scalar/utf8.h Outdated
@lemire

lemire commented Jul 11, 2026

Copy link
Copy Markdown
Member

@BenjaminBucher You should now see my comments. They were in the system but not yet visible.

Benjamin Bucher and others added 14 commits August 8, 2026 00:40
The function was defined in src/implementation.cpp but never declared in a
public header, so simdutf::validate_utf8_with_counts was not callable by
library users: the symbol existed in the binary but no declaration was
visible. Add the missing declaration next to validate_utf8_with_errors.

Also document it in the README: the doc comment and signature in the
validation API listing, plus a short section describing utf8_result and
showing how a single pass yields both the UTF-16 length and the code point
count.
These three backends already route validate_utf8 and
validate_utf8_with_errors through the generic utf8_validation kernel, but
validate_utf8_with_counts still fell back to the scalar implementation.
Route it through the generic kernel as well, as haswell and westmere
already do.
The existing brute-force test corrupts random positions, so it only
exercises 64-byte block boundaries by chance. Add a deterministic sweep that
injects an error at every offset, validates every truncation, and checks
every buffer length around block boundaries, comparing against the scalar
implementation.

This targets the rewind path, where an error near the start of a block has
to backtrack into the previous block and un-count it.
…ii_count

- icelake validate_utf8_with_counts: skip continuation/4-byte popcounts on
  pure-ASCII 64-byte blocks (use avx512_utf8_checker::check_next_input's
  ASCII return), matching SimdUnicode's ASCII fast path. ~1.6x on ASCII/mixed.
- Remove non_ascii_count from utf8_result and all backends: it was computed in
  the hot loop but consumed by nothing (utf16_length needs only input,
  continuations, four_byte; code points need input - continuations).
- Fewer popcounts per non-ASCII block across scalar/generic/icelake.
arm64 already routes validate_utf8/validate_utf8_with_errors through the
generic utf8_validation kernel; do the same for validate_utf8_with_counts.
Measured on Apple M4 Max (64 MB inputs, best-of-N):
  ascii  4.2 -> 97.5 GB/s (23x), mixed 4.2 -> 74 GB/s (18x),
  chinese 3.0 -> 7.3 GB/s (2.4x), emoji 3.6 -> 7.1 GB/s (2x).
Brute-force equality-to-scalar test passes.
…ask+popcount

NEON has no movemask; building a bitmask and popcounting it is costly. Reduce
the continuation / four-byte comparison masks directly with a horizontal byte
add (sum_bytes), one instruction per counter. x86 keeps movemask+popcount via
the #else branch (byte-identical to before, verified unchanged on big4).
Apple M4 Max, validate_utf8_with_counts, 64 MB, best-of-N:
  chinese 7.3 -> 10.6 GB/s (1.45x), emoji 7.1 -> 10.5 GB/s (1.48x);
  ascii/mixed unchanged (counting already skipped on ASCII blocks).
Replace the per-chunk horizontal reduction (sum_bytes/vaddvq) with int8x16
lane accumulators updated by cheap vertical adds; reduce across lanes only
every 124 chunks, on error, and at end. Helps weak cores (Graviton 2) where
cross-lane sums are slow. Arm64-guarded; x86 path unchanged.
Make the generic utf8_checker::check_next_input return whether the block was
pure ASCII (matching the icelake avx512 checker). The NEON counting path uses
it to gate accumulation without a redundant is_ascii() call per block. Existing
validate_utf8 callers ignore the return value.
@BenjaminBucher

Copy link
Copy Markdown
Contributor Author

^ rebased onto master to fix conflicts

@lemire lemire left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few useful comments...

Comment thread src/generic/utf8_validation/utf8_lookup4_algorithm.h Outdated
Comment thread include/simdutf/implementation.h
Comment thread src/generic/utf8_validation/utf8_lookup4_algorithm.h Outdated
Comment thread include/simdutf/scalar/utf8.h Outdated
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.

3 participants