draft: Port GetPointerToFirstInvalidByte from SimdUnicode - #974
draft: Port GetPointerToFirstInvalidByte from SimdUnicode#974BenjaminBucher wants to merge 50 commits into
GetPointerToFirstInvalidByte from SimdUnicode#974Conversation
|
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. |
|
I'm definitely in favor of making this function more generic. |
|
@BenjaminBucher Yeah. What we want is the common case to be fast (which is going to be proper UTF-8). |
|
I'm highly confused by the current compile error: It complains about |
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 ( |
|
@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. |
|
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 |
|
@lemire We got: length (amount of bytes), ncon (amount of continuation bytes), n4 (amount of 4-byte leads) If I didn't miss anything, we only have 2 equations. We'd need to keep track of another variable, which we can do, but will add more runtime overhead not required for utf16 length. |
|
@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 ( Let (L) be the total number of bytes in the string. We define:
We have two expressions for the total byte length Solving the linear system for 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: That's it. Characters = bytes minus continuation bytes. This generic data structure is enough to compute the required UTF-16 output (it is just 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. |
|
With the focus on throughput, I first definitely recoiled from the idea of keeping yet another counter. 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. |
|
@BenjaminBucher I am not arguing that we need to keep track of the ASCII characters. It may indeed be quite cheap. |
|
Fair fair, that sounds to me like a good solution :) |
|
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? |
|
@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? |
|
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 :) |
|
The icelake implementation is embarrassingly simple and I want to propose to do the same in the generic implementation. 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):
I don't have good intuition for where overoptimization starts and where good-enough and simple code is in order. Feedback wanted :) |
|
I’ll get back to you in the next few days. |
|
@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. |
|
@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. |
|
I can hardly call a single day a delay ^^. |
| non_ascii += utf8_count_non_ascii(utf8); | ||
| count += 64; | ||
| } | ||
| const __m512i utf8 = _mm512_maskz_loadu_epi8( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
@BenjaminBucher You should now see my comments. They were in the system but not yet visible. |
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.
e02e7ac to
8bbfb52
Compare
|
^ rebased onto master to fix conflicts |
for consistency with the other functions
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
GetPointerToFirstInvalidBytein 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 charactersbut 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:
Step 1.5: User needs to find / allocate sufficient space.
Step 2: Transcoding with replacement
Open questions
[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 :)