You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[API Proposal]: Ascii.ToUtf16 overload that treats \0 as invalid #80366
For ASP.NET Core's StringUtilities the ASCII values of the range (0x00, 0x80) are considered valid, whilst Ascii.ToUtf16 treats the whole ASCII range [0x00, 0x80) as valid. In order to base StringUtilities on the Ascii-APIs and avoid custom vectorized code in ASP.NET Core internals \0 should be allowed to be treated as invalid. See dotnet/aspnetcore#45962 for further info.
API Proposal
namespace System.Buffers.Text
{
public static class Ascii
{
// existing methods
+ public static OperationStatus ToUtf16(ReadOnlySpan<byte> source, Span<char> destination, out int bytesConsumed, out int charsWritten, bool treatNullAsInvalid = false);
}
}
The new ASCII-APIs will get added to .NET 8, so w/o breaking change an optional argument could be added.
namespace System.Buffers.Text
{
public static class Ascii
{
// existing methods
- public static OperationStatus ToUtf16(ReadOnlySpan<byte> source, Span<char> destination, out int bytesConsumed, out int charsWritten);+ public static OperationStatus ToUtf16(ReadOnlySpan<byte> source, Span<char> destination, out int bytesConsumed, out int charsWritten, bool treatNullAsInvalid = false);
}
}
The value for treatNullAsInvalid will be given as constant, so the JIT should be able to dead-code eliminate any code needed for "default case" (whole ASCII-range incl. \0), so no perf-regression should be expected.
Besides treating \0 as special value which is optinally treated as invalid I don't expect any other value to be considered special enough for optional exclusion.
In local micro-benchmarks yes, mainly due to the $O(2n)$-nature. And of course it depends on the input (length and position of \0).
It would be more interesting to see real-usage benchmarks, like how that would impact Techempower, etc. But unfortunately I don't know how to run such benchmarks (at the moment).
@GrabYourPitchforks had some fairly strong opinions about special-casing '\0'.
99% of the time there won't be any nulls but headers average 800 bytes to 2kB with cookies; so scanning the headers an additional time to check for nulls can be significant
Thanks for these numbers! Out of interest: how / where did you get these from?
Googled average headers size 😅
As an anecdote going to Google homepage logged in my headers are 2158 bytes and it makes 27 requests to that domain (26 to other domains); so in total 58kB for that page and one domain
Wouldn't it be better to treat \0 as special at the highest level, instead of at the lowest level?
For example, I think that when parsing HTTP 1 headers, you could look for \r, \n or \0 as the first step, instead of just \r and \n, and deal with \0 at that point. That would then mean you could safely use the current version of Ascii.ToUtf16 to convert the header bytes to UTF-16.
I am strongly against this proposal. ASCII is defined as characters in the range 0x00 .. 0x7F, inclusive. Sometimes a protocol will exclude certain characters (0x00, or the entire control character range 0x00 .. 0x1F and 0x7F), but at that point you're making something tied to a particular protocol rather than something that is a general-purpose ASCII API. It's similar to the reason we don't support WTF-8 within any of our UTF-8 APIs: certain protocols may utilize it, but it doesn't belong in a general-purpose UTF-8 processing API.
Since this is protocol-specific for aspnet, I recommend the code remain in that project.
Spec wise it shouldn't; however if its a front-end server that passes requests to another server; if and that server uses null terminated strings then the request can change in the second layer accessing url's which weren't mapped to the internet, which could be a security risk (along lines of https://en.wikipedia.org/wiki/HTTP_request_smuggling though different)
Note that one of the primary uses of IndexOfAnyValues is for use in protocols, where protocols need to search for or exempt certain things. Could/should we incorporate that somehow?
As @svick says, it just needs to check for 3 rather than 2, then throw if its \0; is already an api for it
For example, I think that when parsing HTTP 1 headers, you could look for \r, \n or \0 as the first step, instead of just \r and \n, and deal with \0 at that point. That would then mean you could safely use the current version of Ascii.ToUtf16 to convert the header bytes to UTF-16.
For example, I think that when parsing HTTP 1 headers, you could look for \r, \n or \0 as the first step, instead of just \r and \n, and deal with \0 at that point. That would then mean you could safely use the current version of Ascii.ToUtf16 to convert the header bytes to UTF-16.
Is that viable? Can all of the places that call into this shared routine be updated trivially to ensure the data passed in doesn't contain a \0? If so, let's do that, add the AScii.ToUtf16 that's for the whole [0, 127] range, update ASP.NET to use that, and call it a good day.
For example, I think that when parsing HTTP 1 headers, you could look for \r, \n or \0 as the first step, instead of just \r and \n, and deal with \0 at that point. That would then mean you could safely use the current version of Ascii.ToUtf16 to convert the header bytes to UTF-16.
Is that viable? Can all of the places that call into this shared routine be updated trivially to ensure the data passed in doesn't contain a \0? If so, let's do that, add the AScii.ToUtf16 that's for the whole [0, 127] range, update ASP.NET to use that, and call it a good day.
private static ReadOnlySpan<byte> Delimiters => new byte[] { ByteLF, 0 };
if (reader.TryReadToAny(out ReadOnlySpan<byte> requestLine, Delimiters, advancePastDelimiter: true))
{
if (requestLine.Length == 0 || (reader.TryPeek(out var next) && next == 0))
{
RejectRequestLine(requestLine);
}
ParseRequestLine(handler, requestLine);
return true;
}
This is where we start parsing the HTTP/1 request so we're already going through the entire request line looking for \n, this just updates to TryReadToAny to search for \0 at the same time, which I hope is optimized for single pass 😃.
The concerns are:
We're removing the \0 check from the GetAsciiStringNonNullCharacters method which means new callers could accidentally allow null
The errors returned if \0 is found are lacking detail (although a lot faster 😆)
There is some HTTP/2 and HTTP/3 code calling GetAsciiStringNonNullCharacters although it looked like they were supposed to already be null character checking before calling this method
#80245 is open which might be indirectly tracking part of the work to improve the performance.
And there is a recent PR that might improve performance #85266.
I'm not aware of any fundamental reason that should be the case. We should fix anything in the core routine that might be contributing those few additional cycles. I'd hope it's not just the difference between returning a bool and returning an OperationStatus.
Grabbed the assembly of Ascii.ToUtf16 and Kestrel's TryGetAsciiString
One very obvious difference is that the core processing is not inlined in the Ascii.ToUtf16 case. That's the first thing I would try when comparing perf, but it always takes me a couple hours to figure out how to get a custom runtime again, so I haven't tried yet 😆
But if someone wants to take a look at the assembly in the meantime and see if there is anything obviously worse in the Ascii.ToUtf16 case please do!
Ascii.ToUtf16
; Total bytes of code 171
; Assembly listing for method System.Text.Ascii:ToUtf16(System.ReadOnlySpan`1[ubyte],System.Span`1[ushort],byref):int (Tier1)
; Emitting BLENDED_CODE for X64 with AVX - Windows
; Tier1 code
; optimized code
; optimized using Dynamic PGO
; rsp based frame
; partially interruptible
; with Dynamic PGO: edge weights are valid, and fgCalledCount is 17696
; 0 inlinees with PGO data; 2 single block inlinees; 0 inlinees without PGO data
G_M000_IG01: ;; offset=0x0000
push r15
push r14
push rdi
push rsi
push rbp
push rbx
sub rsp, 56
xor eax, eax
mov qword ptr [rsp+0x30], rax
mov qword ptr [rsp+0x28], rax
mov rbx, r8
G_M000_IG02: ;; offset=0x001B
mov rsi, bword ptr [rdx]
mov edi, dword ptr [rdx+0x08]
mov rbp, bword ptr [rcx]
mov ecx, dword ptr [rcx+0x08]
cmp ecx, edi
jg SHORT G_M000_IG05
mov r14d, ecx
xor r15d, r15d
G_M000_IG03: ;; offset=0x0031
mov bword ptr [rsp+0x30], rbp
mov rcx, rbp
mov bword ptr [rsp+0x28], rsi
mov rdx, rsi
mov r8, r14
call [System.Text.Ascii:WidenAsciiToUtf16(ulong,ulong,ulong):ulong]
mov dword ptr [rbx], eax
mov ecx, 3
cmp r14, rax
mov eax, ecx
cmove eax, r15d
G_M000_IG04: ;; offset=0x005A
add rsp, 56
pop rbx
pop rbp
pop rsi
pop rdi
pop r14
pop r15
ret
G_M000_IG05: ;; offset=0x0067
mov r14d, edi
mov r15d, 1
jmp SHORT G_M000_IG03
; Total bytes of code 114
-----------------------------------------------------------------------------------------------------------------------------------------
; Assembly listing for method System.Text.Ascii:WidenAsciiToUtf16(ulong,ulong,ulong):ulong (Tier1)
; Emitting BLENDED_CODE for X64 with AVX - Windows
; Tier1 code
; optimized code
; optimized using Dynamic PGO
; rsp based frame
; fully interruptible
; with Dynamic PGO: edge weights are valid, and fgCalledCount is 12930
; 0 inlinees with PGO data; 6 single block inlinees; 2 inlinees without PGO data
G_M000_IG01: ;; offset=0x0000
vzeroupper
G_M000_IG02: ;; offset=0x0003
xor eax, eax
cmp r8, 16
jb SHORT G_M000_IG04
mov r10, rdx
cmp r8, 32
jb G_M000_IG11
lea r9, [r8-0x20]
G_M000_IG03: ;; offset=0x001C
vmovups ymm0, ymmword ptr [rcx+rax]
vpmovmskb r11d, ymm0
test r11d, r11d
jne SHORT G_M000_IG04
vmovaps ymm1, ymm0
vpmovzxbw ymm1, ymm1
vextracti128 xmm0, ymm0, 1
vpmovzxbw ymm0, ymm0
vmovups ymmword ptr [r10], ymm1
vmovups ymmword ptr [r10+0x20], ymm0
add rax, 32
add r10, 64
cmp rax, r9
jbe SHORT G_M000_IG03
G_M000_IG04: ;; offset=0x0056
sub r8, rax
cmp r8, 4
jb SHORT G_M000_IG07
G_M000_IG05: ;; offset=0x005F
lea r10, [rax+r8-0x04]
align [0 bytes for IG06]
G_M000_IG06: ;; offset=0x0064
mov r9d, dword ptr [rcx+rax]
test r9d, 0xFFFFFFFF80808080
jne SHORT G_M000_IG10
vmovd xmm0, r9
vpmovzxbw xmm0, xmm0
vmovd qword ptr [rdx+2*rax], xmm0
add rax, 4
cmp rax, r10
jbe SHORT G_M000_IG06
G_M000_IG07: ;; offset=0x008A
test r8b, 2
jne SHORT G_M000_IG13
test r8b, 1
jne G_M000_IG14
G_M000_IG08: ;; offset=0x009A
vzeroupper
ret
G_M000_IG09: ;; offset=0x009E
movzx rcx, r9b
mov word ptr [rdx+2*rax], cx
inc rax
shr r9d, 8
G_M000_IG10: ;; offset=0x00AD
movzx rcx, r9b
test cl, 128
je SHORT G_M000_IG09
jmp SHORT G_M000_IG08
G_M000_IG11: ;; offset=0x00B8
lea r9, [r8-0x10]
G_M000_IG12: ;; offset=0x00BC
vmovups xmm0, xmmword ptr [rcx+rax]
vptest xmm0, xmmword ptr [reloc @RWD00]
jne SHORT G_M000_IG04
vpmovzxbw xmm1, xmm0
vpsrldq xmm0, xmm0, 8
vpmovzxbw xmm0, xmm0
vmovups xmmword ptr [r10], xmm1
vmovups xmmword ptr [r10+0x10], xmm0
add rax, 16
add r10, 32
cmp rax, r9
jbe SHORT G_M000_IG12
jmp G_M000_IG04
G_M000_IG13: ;; offset=0x00F8
movzx r9, word ptr [rcx+rax]
test r9d, 0xFFFFFFFF80808080
jne SHORT G_M000_IG10
movzx r10, r9b
mov word ptr [rdx+2*rax], r10w
shr r9d, 8
mov word ptr [rdx+2*rax+0x02], r9w
add rax, 2
test r8b, 1
je G_M000_IG08
G_M000_IG14: ;; offset=0x0127
movzx r9, byte ptr [rcx+rax]
test r9b, 128
jne G_M000_IG08
mov word ptr [rdx+2*rax], r9w
inc rax
jmp G_M000_IG08
RWD00 dq 8080808080808080h, 8080808080808080h
; Total bytes of code 323
TryGetAsciiString
; Assembly listing for method StringUtilities:TryGetAsciiString(ulong,ulong,int):bool (Tier1)
; Emitting BLENDED_CODE for X64 with AVX - Windows
; Tier1 code
; optimized code
; rsp based frame
; fully interruptible
; No PGO data
; 0 inlinees with PGO data; 8 single block inlinees; 2 inlinees without PGO data
G_M000_IG01: ;; offset=0x0000
vzeroupper
G_M000_IG02: ;; offset=0x0003
movsxd rax, r8d
add rax, rcx
lea r8, [rax-0x20]
cmp rcx, r8
ja SHORT G_M000_IG05
align [0 bytes for IG03]
G_M000_IG03: ;; offset=0x0012
vmovups ymm0, ymmword ptr [rcx]
vxorps ymm1, ymm1, ymm1
vpcmpgtb ymm1, ymm0, ymm1
vpmovmskb r10d, ymm1
cmp r10d, -1
jne G_M000_IG15
vxorps ymm1, ymm1, ymm1
vpunpcklbw ymm1, ymm0, ymm1
vxorps ymm2, ymm2, ymm2
vpunpckhbw ymm0, ymm0, ymm2
vperm2i128 ymm2, ymm1, ymm0, 32
vperm2i128 ymm0, ymm1, ymm0, 49
vmovups ymmword ptr [rdx], ymm2
vmovups ymmword ptr [rdx+0x20], ymm0
add rcx, 32
add rdx, 64
cmp rcx, r8
jbe SHORT G_M000_IG03
G_M000_IG04: ;; offset=0x005E
cmp rcx, rax
je G_M000_IG13
G_M000_IG05: ;; offset=0x0067
lea r8, [rax-0x10]
cmp rcx, r8
ja SHORT G_M000_IG08
align [0 bytes for IG06]
G_M000_IG06: ;; offset=0x0070
vmovups xmm0, xmmword ptr [rcx]
vxorps xmm1, xmm1, xmm1
vpcmpgtb xmm1, xmm0, xmm1
vpmovmskb r10d, xmm1
cmp r10d, 0xFFFF
jne G_M000_IG15
vxorps xmm1, xmm1, xmm1
vpunpcklbw xmm1, xmm0, xmm1
vxorps xmm2, xmm2, xmm2
vpunpckhbw xmm0, xmm0, xmm2
vmovups xmmword ptr [rdx], xmm1
vmovups xmmword ptr [rdx+0x10], xmm0
add rcx, 16
add rdx, 32
cmp rcx, r8
jbe SHORT G_M000_IG06
G_M000_IG07: ;; offset=0x00B3
cmp rcx, rax
je G_M000_IG13
G_M000_IG08: ;; offset=0x00BC
lea r8, [rax-0x08]
cmp rcx, r8
ja SHORT G_M000_IG10
align [0 bytes for IG09]
G_M000_IG09: ;; offset=0x00C5
mov r10, qword ptr [rcx]
mov r9, 0xFEFEFEFEFEFEFEFF
add r9, r10
or r9, r10
mov r11, 0x8080808080808080
test r9, r11
jne G_M000_IG15
vmovd xmm0, r10
vxorps xmm1, xmm1, xmm1
vpunpcklbw xmm0, xmm0, xmm1
vmovups xmmword ptr [rdx], xmm0
add rcx, 8
add rdx, 16
cmp rcx, r8
jbe SHORT G_M000_IG09
G_M000_IG10: ;; offset=0x0109
lea r8, [rax-0x04]
cmp rcx, r8
ja SHORT G_M000_IG11
mov r8d, dword ptr [rcx]
lea r10d, [r8+0xFEFEFEFF]
or r10d, r8d
test r10d, 0xFFFFFFFF80808080
jne SHORT G_M000_IG15
vmovd xmm0, r8
vxorps xmm1, xmm1, xmm1
vpunpcklbw xmm0, xmm0, xmm1
vmovd qword ptr [rdx], xmm0
add rcx, 4
add rdx, 8
G_M000_IG11: ;; offset=0x0142
lea r8, [rax-0x02]
cmp rcx, r8
ja SHORT G_M000_IG12
movsx r8, word ptr [rcx]
lea r10d, [r8-0x101]
movsx r10, r10w
or r8d, r10d
test r8d, -0x7F80
jne SHORT G_M000_IG15
movzx r8, byte ptr [rcx]
mov word ptr [rdx], r8w
movzx r8, byte ptr [rcx+0x01]
mov word ptr [rdx+0x02], r8w
add rcx, 2
add rdx, 4
G_M000_IG12: ;; offset=0x0180
cmp rcx, rax
jae SHORT G_M000_IG13
cmp byte ptr [rcx], 0
jle SHORT G_M000_IG15
movzx rax, byte ptr [rcx]
mov word ptr [rdx], ax
G_M000_IG13: ;; offset=0x0190
mov eax, 1
G_M000_IG14: ;; offset=0x0195
vzeroupper
ret
G_M000_IG15: ;; offset=0x0199
xor eax, eax
G_M000_IG16: ;; offset=0x019B
vzeroupper
ret
; Total bytes of code 415
Activity
stephentoub commentedon Jan 9, 2023
Does the ASP.NET code measurably regress if the newly-added ToUtf16 is used plus a call to IndexOf((byte)'\0) to validate there wasn't a null?
@GrabYourPitchforks had some fairly strong opinions about special-casing
'\0'.gfoidl commentedon Jan 9, 2023
In local micro-benchmarks yes, mainly due to the$O(2n)$ -nature. And of course it depends on the input (length and position of
\0).It would be more interesting to see real-usage benchmarks, like how that would impact Techempower, etc. But unfortunately I don't know how to run such benchmarks (at the moment).
I'm looking forward to read them.
benaadams commentedon Jan 9, 2023
99% of the time there won't be any nulls but headers average 800 bytes to 2kB with cookies; so scanning the headers an additional time to check for nulls can be significant
gfoidl commentedon Jan 9, 2023
Thanks for these numbers!
Out of interest: how / where did you get these from?
benaadams commentedon Jan 9, 2023
Googled average headers size 😅
As an anecdote going to Google homepage logged in my headers are 2158 bytes and it makes 27 requests to that domain (26 to other domains); so in total 58kB for that page and one domain
svick commentedon Jan 9, 2023
Wouldn't it be better to treat
\0as special at the highest level, instead of at the lowest level?For example, I think that when parsing HTTP 1 headers, you could look for
\r,\nor\0as the first step, instead of just\rand\n, and deal with\0at that point. That would then mean you could safely use the current version ofAscii.ToUtf16to convert the header bytes to UTF-16.tannergooding commentedon Mar 23, 2023
Assigning to @GrabYourPitchforks for now until the necessary input can be given.
GrabYourPitchforks commentedon Mar 23, 2023
I am strongly against this proposal. ASCII is defined as characters in the range
0x00 .. 0x7F, inclusive. Sometimes a protocol will exclude certain characters (0x00, or the entire control character range0x00 .. 0x1Fand0x7F), but at that point you're making something tied to a particular protocol rather than something that is a general-purpose ASCII API. It's similar to the reason we don't support WTF-8 within any of our UTF-8 APIs: certain protocols may utilize it, but it doesn't belong in a general-purpose UTF-8 processing API.Since this is protocol-specific for aspnet, I recommend the code remain in that project.
3 remaining items
benaadams commentedon Mar 23, 2023
Spec wise it shouldn't; however if its a front-end server that passes requests to another server; if and that server uses null terminated strings then the request can change in the second layer accessing url's which weren't mapped to the internet, which could be a security risk (along lines of https://en.wikipedia.org/wiki/HTTP_request_smuggling though different)
benaadams commentedon Mar 23, 2023
As @svick says, it just needs to check for 3 rather than 2, then throw if its
\0; is already an api for itstephentoub commentedon Mar 24, 2023
I hadn't seen @svick's comment:
Is that viable? Can all of the places that call into this shared routine be updated trivially to ensure the data passed in doesn't contain a \0? If so, let's do that, add the AScii.ToUtf16 that's for the whole [0, 127] range, update ASP.NET to use that, and call it a good day.
stephentoub commentedon Apr 28, 2023
@BrennanConroy, do you have a suggestion for how we could make forward progress on this?
BrennanConroy commentedon Apr 29, 2023
Looks like it would be "easy" to do this. If we updated https://github.com/dotnet/aspnetcore/blob/f62f12357c49c4f1cca502e8f4cf57353f0b320f/src/Servers/Kestrel/Core/src/Internal/Http/HttpParser.cs#LL64C37-L64C37 to instead be
This is where we start parsing the HTTP/1 request so we're already going through the entire request line looking for
\n, this just updates toTryReadToAnyto search for\0at the same time, which I hope is optimized for single pass 😃.The concerns are:
\0check from theGetAsciiStringNonNullCharactersmethod which means new callers could accidentally allow null\0is found are lacking detail (although a lot faster 😆)GetAsciiStringNonNullCharactersalthough it looked like they were supposed to already be null character checking before calling this methodstephentoub commentedon Apr 29, 2023
Thanks, I sketched it out in:
dotnet/aspnetcore@main...stephentoub:aspnetcore:asciitoutf16
Not exactly what you suggested, but similar.
A bunch of tests failed, and I haven't gone through to see which would be expected (the tests have internals access) and which would be real problems.
Any interest in picking it up and seeing how far we can run with it?
BrennanConroy commentedon Apr 30, 2023
Yeah, I'll pick it up and see what the team thinks
BrennanConroy commentedon May 8, 2023
It looks like
Ascii.ToUtf16is still slower than the custom code in aspnetcore.dotnet/aspnetcore#45962 (comment)
#80245 is open which might be indirectly tracking part of the work to improve the performance.
And there is a recent PR that might improve performance #85266.
stephentoub commentedon May 8, 2023
I'm not aware of any fundamental reason that should be the case. We should fix anything in the core routine that might be contributing those few additional cycles. I'd hope it's not just the difference between returning a bool and returning an OperationStatus.
cc: @adamsitnik, @GrabYourPitchforks
BrennanConroy commentedon Oct 6, 2023
Grabbed the assembly of
Ascii.ToUtf16and Kestrel'sTryGetAsciiStringOne very obvious difference is that the core processing is not inlined in the
Ascii.ToUtf16case. That's the first thing I would try when comparing perf, but it always takes me a couple hours to figure out how to get a custom runtime again, so I haven't tried yet 😆But if someone wants to take a look at the assembly in the meantime and see if there is anything obviously worse in the
Ascii.ToUtf16case please do!Ascii.ToUtf16
TryGetAsciiString
GrabYourPitchforks commentedon Oct 9, 2023
Below are the results I'm getting on my machine. This seems very much within the range of noise.
@BrennanConroy Are you seeing different results than below?
stephentoub commentedon Jan 15, 2024
@BrennanConroy, can you comment on the above?
stephentoub commentedon Jul 19, 2024
Closing given dotnet/aspnetcore#56578