Add a fast path to IndexOfQuoteOrAnyControlOrBackSlash#126700
Add a fast path to IndexOfQuoteOrAnyControlOrBackSlash#126700EgorBo wants to merge 9 commits intodotnet:mainfrom
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.
src/libraries/System.Text.Json/src/System/Text/Json/Reader/JsonReaderHelper.net8.cs
Outdated
Show resolved
Hide resolved
…nReaderHelper.net8.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@EgorBot -linux_azure_arm -arm -linux_aws_arm -profiler using System.Text;
using System.Text.Json;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser]
public class Benchmarks
{
// ── TokenSerialization fields ────────────────────────────────────────────
private List<object> _tokenObjects;
[ThreadStatic] static Utf8JsonWriter t_writer;
[ThreadStatic] static MemoryStream t_stream;
[GlobalSetup]
public void Setup()
{
// TokenSerialization
_tokenObjects = new List<object>(200);
for (int i = 0; i < 200; i++)
{
if (i % 3 == 0)
_tokenObjects.Add(GenerateRecordJson(1));
else
_tokenObjects.Add(new Dictionary<string, object>
{
["seq"] = i,
["label"] = $"item_{i}",
["blob"] = new byte[100]
});
}
}
private static string GenerateRecordJson(int targetSizeKb = 150)
{
var sb = new StringBuilder(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\":[");
int targetBytes = targetSizeKb * 1024;
int fieldIndex = 0;
bool firstField = 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("}");
return sb.ToString();
}
[Benchmark]
public void TokenSerialization()
{
var stream = t_stream ??= new MemoryStream(64 * 1024);
stream.Position = 0;
stream.SetLength(0);
var writer = t_writer;
if (writer == null)
{
writer = new Utf8JsonWriter(stream, new JsonWriterOptions { SkipValidation = true });
t_writer = writer;
}
else
writer.Reset(stream);
writer.WriteStartObject();
writer.WriteStartArray("Catalog");
foreach (var token in _tokenObjects)
{
if (token is string strToken)
{
if (!string.IsNullOrEmpty(strToken))
writer.WriteRawValue(strToken);
}
else if (token is Dictionary<string, object> dictToken)
{
writer.WriteStartObject();
foreach (var kvp in dictToken)
{
writer.WritePropertyName(kvp.Key);
JsonSerializer.Serialize(writer, kvp.Value);
}
writer.WriteEndObject();
}
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
if (stream.Length == 0) throw new Exception("unreachable");
}
} |
src/libraries/System.Text.Json/src/System/Text/Json/Reader/JsonReaderHelper.net8.cs
Show resolved
Hide resolved
|
@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. |
src/libraries/System.Text.Json/src/System/Text/Json/Reader/JsonReaderHelper.net8.cs
Show resolved
Hide resolved
src/libraries/System.Text.Json/src/System/Text/Json/Reader/JsonReaderHelper.net8.cs
Outdated
Show resolved
Hide resolved
…nReaderHelper.net8.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| ref byte searchSpace = ref MemoryMarshal.GetReference(span); | ||
| unsafe | ||
| { | ||
| fixed (byte* ptr = &searchSpace) | ||
| { |
There was a problem hiding this comment.
MemoryMarshal is used here but this file doesn't import System.Runtime.InteropServices (and there are no global usings), so this won't compile. Add using System.Runtime.InteropServices; at the top or fully-qualify MemoryMarshal at the usage site.
| if (Sve.IsSupported) | ||
| { | ||
| // SVE: Use predicated comparisons + brkb + cntp to find the index. | ||
| // The result flows through predicate registers directly to a scalar cntp — | ||
| // no SIMD-to-GP transfer (UMOV) is needed at all. | ||
| // On V2: cmpeq(4cy) + cmplo(4cy) + orr(1cy) + brkb(2cy) + cntp(2cy) |
There was a problem hiding this comment.
In the Sve.IsSupported path, if there is no match in the first 16 bytes, execution falls through and repeats the same search using Vector128/AdvSimd before calling the fallback. Since the SVE check already proved there is no match in the first 16 bytes, consider returning directly to the fallback on span.Slice(Vector128<byte>.Count) to avoid the redundant work on SVE-capable machines.
| Vector<byte> combined = Sve.CompareEqual(data, new Vector<byte>((byte)'"')) | ||
| | Sve.CompareEqual(data, new Vector<byte>((byte)'\\')) | ||
| | Sve.CompareLessThan(data, new Vector<byte>((byte)0x20)); |
There was a problem hiding this comment.
The SVE path hardcodes ", \\, and 0x20 instead of using the existing JsonConstants.Quote / JsonConstants.BackSlash / JsonConstants.Space used later in the method. Using the shared constants would keep all paths consistent and avoid accidental divergence if the definitions ever change.
| Vector<byte> combined = Sve.CompareEqual(data, new Vector<byte>((byte)'"')) | |
| | Sve.CompareEqual(data, new Vector<byte>((byte)'\\')) | |
| | Sve.CompareLessThan(data, new Vector<byte>((byte)0x20)); | |
| Vector<byte> combined = Sve.CompareEqual(data, new Vector<byte>(JsonConstants.Quote)) | |
| | Sve.CompareEqual(data, new Vector<byte>(JsonConstants.BackSlash)) | |
| | Sve.CompareLessThan(data, new Vector<byte>(JsonConstants.Space)); |
| return IndexOfQuoteOrAnyControlOrBackSlash_Fallback(span); | ||
|
|
||
| #pragma warning disable SYSLIB5003 // SVE is experimental | ||
| if (Sve.IsSupported) |
There was a problem hiding this comment.
nit: If we're going to check this path in, it really needs to check Vector.IsHardwareAccelerated and span.Length >= Vector<byte>.Count so that it remains correct in the future as well.
Removed experimental SVE code for finding index of quote or control characters.
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: