Uh oh!
There was an error while loading. Please reload this page.
Add a fast path to IndexOfQuoteOrAnyControlOrBackSlash - #126700
Conversation
Tagging subscribers to this area: @dotnet/area-system-text-json |
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
Pull request overview
Adds a SIMD fast-path to JsonReaderHelper.IndexOfQuoteOrAnyControlOrBackSlash to quickly detect " / \ / control characters by scanning the first 16 bytes before falling back to SearchValues<byte>-based searching.
Changes:
- Introduces a
Vector128<byte>-based first-16-bytes scan for quote/backslash/control bytes. - Adds an ARM64-specific mask extraction path using
AdvSimdplusBitOperations.TrailingZeroCount. - Moves the existing
IndexOfAny(SearchValues<byte>)implementation into a non-inlined fallback helper.
Uh oh!
There was an error while loading. Please reload this page.
…nReaderHelper.net8.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
EgorBo
commented
Apr 9, 2026
@EgorBot -linux_azure_arm -arm -linux_aws_arm -profiler usingSystem.Text;usingSystem.Text.Json;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);[MemoryDiagnoser]publicclassBenchmarks{// ── TokenSerialization fields ────────────────────────────────────────────privateList<object>_tokenObjects;[ThreadStatic]staticUtf8JsonWritert_writer;[ThreadStatic]staticMemoryStreamt_stream;[GlobalSetup]publicvoidSetup(){// TokenSerialization_tokenObjects=newList<object>(200);for(inti=0;i<200;i++){if(i%3==0)_tokenObjects.Add(GenerateRecordJson(1));else_tokenObjects.Add(newDictionary<string,object>{["seq"]=i,["label"]=$"item_{i}",["blob"]=newbyte[100]});}}privatestaticstringGenerateRecordJson(inttargetSizeKb=150){varsb=newStringBuilder(targetSizeKb*1024+512);sb.Append("{");sb.Append("\"TypeName\":\"product\",");sb.Append("\"CategoryCode\":1,");sb.Append("\"Label\":\"Product\",");sb.Append("\"IsAction\":false,");sb.Append("\"IsActionMember\":false,");sb.Append("\"IsTrackingEnabled\":true,");sb.Append("\"IsAvailableLocal\":true,");sb.Append("\"IsChildRecord\":false,");sb.Append("\"IsLinksEnabled\":true,");sb.Append("\"IsCustomRecord\":false,");sb.Append("\"PrimaryKeyField\":\"productid\",");sb.Append("\"PrimaryLabelField\":\"title\",");sb.Append("\"Fields\":[");inttargetBytes=targetSizeKb*1024;intfieldIndex=0;boolfirstField=true;while(sb.Length<targetBytes-512){if(!firstField)sb.Append(",");firstField=false;sb.Append("{");sb.Append($"\"TypeName\":\"field_{fieldIndex}\",");sb.Append($"\"InternalName\":\"Field_{fieldIndex}\",");sb.Append($"\"FieldType\":\"String\",");sb.Append($"\"Label\":\"Field {fieldIndex}\",");sb.Append($"\"MaxSize\":100,");sb.Append($"\"IsReadable\":true,");sb.Append($"\"IsCreatable\":true,");sb.Append($"\"IsUpdatable\":true,");sb.Append($"\"IsTrackingEnabled\":false,");sb.Append($"\"IsPrimaryKey\":false,");sb.Append($"\"IsVirtual\":false,");sb.Append($"\"Requirement\":\"None\"");sb.Append("}");fieldIndex++;}sb.Append("]");sb.Append("}");returnsb.ToString();}[Benchmark]publicvoidTokenSerialization(){varstream=t_stream??=newMemoryStream(64*1024);stream.Position=0;stream.SetLength(0);varwriter=t_writer;if(writer==null){writer=newUtf8JsonWriter(stream,newJsonWriterOptions{SkipValidation=true});t_writer=writer;}elsewriter.Reset(stream);writer.WriteStartObject();writer.WriteStartArray("Catalog");foreach(vartokenin_tokenObjects){if(tokenisstringstrToken){if(!string.IsNullOrEmpty(strToken))writer.WriteRawValue(strToken);}elseif(tokenisDictionary<string,object>dictToken){writer.WriteStartObject();foreach(varkvpindictToken){writer.WritePropertyName(kvp.Key);JsonSerializer.Serialize(writer,kvp.Value);}writer.WriteEndObject();}}writer.WriteEndArray();writer.WriteEndObject();writer.Flush();if(stream.Length==0)thrownewException("unreachable");}} |
Uh oh!
There was an error while loading. Please reload this page.
EgorBo
commented
Apr 9, 2026
@tannergooding I decided to implement the idea we discussed yesterday, I think your PR makes sense to check in too. I couldn't detect more improvements from extending 16 bytes to 32 bytes so decided to keep as is. The fallback doesn't show up in the traces. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…nReaderHelper.net8.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Removed experimental SVE code for finding index of quote or control characters.
EgorBo
commented
Apr 10, 2026
@tannergooding do we need anything else here? I am cooking a PR to intrinsify |
tannergooding
commented
Apr 10, 2026
This LGTM to me as a fast path optimization. We can cleanup more as JIT optimizations come online and if we improve SearchValues so that it can be used directly after the appropriate slice occurs. We probably want weigh-in from @eiriktsarpalis as well to ensure that 16 is the "right" size and not just a size that's optimal for this particular first party scenario (also cc. @jeffhandley as an fyi). |
EgorBo
commented
Apr 13, 2026
@EgorBot -linux_azure_arm -arm -linux_aws_arm usingSystem.Text;usingSystem.Text.Json;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);[MemoryDiagnoser]publicclassBenchmarks{// ── TokenSerialization fields ────────────────────────────────────────────privateList<object>_tokenObjects;[ThreadStatic]staticUtf8JsonWritert_writer;[ThreadStatic]staticMemoryStreamt_stream;[GlobalSetup]publicvoidSetup(){// TokenSerialization_tokenObjects=newList<object>(200);for(inti=0;i<200;i++){if(i%3==0)_tokenObjects.Add(GenerateRecordJson(1));else_tokenObjects.Add(newDictionary<string,object>{["seq"]=i,["label"]=$"item_{i}",["blob"]=newbyte[100]});}}privatestaticstringGenerateRecordJson(inttargetSizeKb=150){varsb=newStringBuilder(targetSizeKb*1024+512);sb.Append("{");sb.Append("\"TypeName\":\"product\",");sb.Append("\"CategoryCode\":1,");sb.Append("\"Label\":\"Product\",");sb.Append("\"IsAction\":false,");sb.Append("\"IsActionMember\":false,");sb.Append("\"IsTrackingEnabled\":true,");sb.Append("\"IsAvailableLocal\":true,");sb.Append("\"IsChildRecord\":false,");sb.Append("\"IsLinksEnabled\":true,");sb.Append("\"IsCustomRecord\":false,");sb.Append("\"PrimaryKeyField\":\"productid\",");sb.Append("\"PrimaryLabelField\":\"title\",");sb.Append("\"Fields\":[");inttargetBytes=targetSizeKb*1024;intfieldIndex=0;boolfirstField=true;while(sb.Length<targetBytes-512){if(!firstField)sb.Append(",");firstField=false;sb.Append("{");sb.Append($"\"TypeName\":\"field_{fieldIndex}\",");sb.Append($"\"InternalName\":\"Field_{fieldIndex}\",");sb.Append($"\"FieldType\":\"String\",");sb.Append($"\"Label\":\"Field {fieldIndex}\",");sb.Append($"\"MaxSize\":100,");sb.Append($"\"IsReadable\":true,");sb.Append($"\"IsCreatable\":true,");sb.Append($"\"IsUpdatable\":true,");sb.Append($"\"IsTrackingEnabled\":false,");sb.Append($"\"IsPrimaryKey\":false,");sb.Append($"\"IsVirtual\":false,");sb.Append($"\"Requirement\":\"None\"");sb.Append("}");fieldIndex++;}sb.Append("]");sb.Append("}");returnsb.ToString();}[Benchmark]publicvoidTokenSerialization(){varstream=t_stream??=newMemoryStream(64*1024);stream.Position=0;stream.SetLength(0);varwriter=t_writer;if(writer==null){writer=newUtf8JsonWriter(stream,newJsonWriterOptions{SkipValidation=true});t_writer=writer;}elsewriter.Reset(stream);writer.WriteStartObject();writer.WriteStartArray("Catalog");foreach(vartokenin_tokenObjects){if(tokenisstringstrToken){if(!string.IsNullOrEmpty(strToken))writer.WriteRawValue(strToken);}elseif(tokenisDictionary<string,object>dictToken){writer.WriteStartObject();foreach(varkvpindictToken){writer.WritePropertyName(kvp.Key);JsonSerializer.Serialize(writer,kvp.Value);}writer.WriteEndObject();}}writer.WriteEndArray();writer.WriteEndObject();writer.Flush();if(stream.Length==0)thrownewException("unreachable");}} |
| using System.Numerics; | ||
| using System.Runtime.CompilerServices; | ||
| using System.Runtime.Intrinsics; | ||
| using System.Runtime.Intrinsics.Arm; |
| if (cmp != Vector128<byte>.Zero) | ||
| { | ||
| ulong mask = AdvSimd.ShiftRightLogicalNarrowingLower(cmp.AsUInt16(), 4).AsUInt64().ToScalar(); | ||
| if (mask != 0) | ||
| return BitOperations.TrailingZeroCount(mask) >> 2; | ||
| } | ||
| else | ||
| { | ||
| uint mask = cmp.ExtractMostSignificantBits(); | ||
| if (mask != 0) | ||
| return BitOperations.TrailingZeroCount(mask); | ||
| return Vector128.IndexOfWhereAllBitsSet(cmp); |
There was a problem hiding this comment.
Given we're expecting a match within the first vector, is this "better" than:
intindex=Vector128.IndexOfWhereAllBitsSet(cmp);if(index>=0){returnindex;}I'd imagine its generating an extra test/extract for a case we presume is unlikely.
There was a problem hiding this comment.
I can test with the both options. I think last time I checked cmp != Vector128<byte>.Zero didn't add overhead, but it might reduce it if there is no match in the first vector
EgorBo
commented
Apr 13, 2026
@EgorBot -linux_azure_arm -arm -linux_aws_arm -profiler usingSystem.Text;usingSystem.Text.Json;usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);[MemoryDiagnoser]publicclassBenchmarks{// ── TokenSerialization fields ────────────────────────────────────────────privateList<object>_tokenObjects;[ThreadStatic]staticUtf8JsonWritert_writer;[ThreadStatic]staticMemoryStreamt_stream;[GlobalSetup]publicvoidSetup(){// TokenSerialization_tokenObjects=newList<object>(200);for(inti=0;i<200;i++){if(i%3==0)_tokenObjects.Add(GenerateRecordJson(1));else_tokenObjects.Add(newDictionary<string,object>{["seq"]=i,["label"]=$"item_{i}",["blob"]=newbyte[100]});}}privatestaticstringGenerateRecordJson(inttargetSizeKb=150){varsb=newStringBuilder(targetSizeKb*1024+512);sb.Append("{");sb.Append("\"TypeName\":\"product\",");sb.Append("\"CategoryCode\":1,");sb.Append("\"Label\":\"Product\",");sb.Append("\"IsAction\":false,");sb.Append("\"IsActionMember\":false,");sb.Append("\"IsTrackingEnabled\":true,");sb.Append("\"IsAvailableLocal\":true,");sb.Append("\"IsChildRecord\":false,");sb.Append("\"IsLinksEnabled\":true,");sb.Append("\"IsCustomRecord\":false,");sb.Append("\"PrimaryKeyField\":\"productid\",");sb.Append("\"PrimaryLabelField\":\"title\",");sb.Append("\"Fields\":[");inttargetBytes=targetSizeKb*1024;intfieldIndex=0;boolfirstField=true;while(sb.Length<targetBytes-512){if(!firstField)sb.Append(",");firstField=false;sb.Append("{");sb.Append($"\"TypeName\":\"field_{fieldIndex}\",");sb.Append($"\"InternalName\":\"Field_{fieldIndex}\",");sb.Append($"\"FieldType\":\"String\",");sb.Append($"\"Label\":\"Field {fieldIndex}\",");sb.Append($"\"MaxSize\":100,");sb.Append($"\"IsReadable\":true,");sb.Append($"\"IsCreatable\":true,");sb.Append($"\"IsUpdatable\":true,");sb.Append($"\"IsTrackingEnabled\":false,");sb.Append($"\"IsPrimaryKey\":false,");sb.Append($"\"IsVirtual\":false,");sb.Append($"\"Requirement\":\"None\"");sb.Append("}");fieldIndex++;}sb.Append("]");sb.Append("}");returnsb.ToString();}[Benchmark]publicvoidTokenSerialization(){varstream=t_stream??=newMemoryStream(64*1024);stream.Position=0;stream.SetLength(0);varwriter=t_writer;if(writer==null){writer=newUtf8JsonWriter(stream,newJsonWriterOptions{SkipValidation=true});t_writer=writer;}elsewriter.Reset(stream);writer.WriteStartObject();writer.WriteStartArray("Catalog");foreach(vartokenin_tokenObjects){if(tokenisstringstrToken){if(!string.IsNullOrEmpty(strToken))writer.WriteRawValue(strToken);}elseif(tokenisDictionary<string,object>dictToken){writer.WriteStartObject();foreach(varkvpindictToken){writer.WritePropertyName(kvp.Key);JsonSerializer.Serialize(writer,kvp.Value);}writer.WriteEndObject();}}writer.WriteEndArray();writer.WriteEndObject();writer.Flush();if(stream.Length==0)thrownewException("unreachable");}} |
EgorBo
commented
Apr 13, 2026
Seems like after #126678 this PR no longer produces clear wins (suprisingly). It seems slow EMSB was the problem. Closing. |
Validate the theory we came up with @tannergooding that we mostly find
"character within first 16 bytes in this function (and the span is most of the time is bigger than 16 bytes) - e.g. the end of a property nameThis doesn't replace #126678, just special cases for JSON where we indeed can assume something is usually found early.
Benchmark - 10-13% improvement on Cobalt100
arm64 codegen: