Skip to content

Commit f0d8db0

Browse files
committed
further optimization of deserialization
1 parent f70f664 commit f0d8db0

2 files changed

Lines changed: 131 additions & 28 deletions

File tree

src/Thinktecture.Runtime.Extensions.Json/Internal/Utf8JsonReaderHelper.cs

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace Thinktecture.Internal;
1212
/// any release. You should only use it directly in your code with extreme caution and knowing that
1313
/// doing so can result in application failures when updating to a new Thinktecture.Runtime.Extensions release.
1414
/// </summary>
15+
[SkipLocalsInit]
1516
public static class Utf8JsonReaderHelper
1617
{
1718
// Aligned with System.Text.Json's JsonConstants.StackallocCharThreshold (= StackallocByteThreshold / 2 = 128)
@@ -32,34 +33,39 @@ public static class Utf8JsonReaderHelper
3233
where T : IObjectFactory<T, ReadOnlySpan<char>, TValidationError>
3334
where TValidationError : class, IValidationError<TValidationError>
3435
{
35-
// Fast path: contiguous, unescaped value (most common case)
36-
if (!reader.HasValueSequence && !reader.ValueIsEscaped)
36+
// Escaped values (rarest case): CopyString handles unescaping and reassembly
37+
if (reader.ValueIsEscaped)
38+
return ValidateEscaped<T, TValidationError>(ref reader, provider, out result);
39+
40+
// Fragmented but not escaped: assemble bytes, then transcode
41+
if (reader.HasValueSequence)
42+
return ValidateFragmentedUnescaped<T, TValidationError>(ref reader, provider, out result);
43+
44+
// Fast path: contiguous, unescaped, short value (most common case)
45+
var utf8Bytes = reader.ValueSpan;
46+
47+
if (utf8Bytes.Length <= _STACKALLOC_CHAR_THRESHOLD)
3748
{
38-
return ValidateFastPath<T, TValidationError>(reader.ValueSpan, provider, out result);
49+
// Constant size enables JIT to emit a simple stack bump instead of localloc
50+
Span<char> charBuf = stackalloc char[_STACKALLOC_CHAR_THRESHOLD];
51+
var charsWritten = Encoding.UTF8.GetChars(utf8Bytes, charBuf);
52+
return T.Validate(charBuf[..charsWritten], provider, out result);
3953
}
4054

41-
// Slow path: escaped or fragmented value
42-
return ValidateSlowPath<T, TValidationError>(ref reader, provider, out result);
55+
// Large contiguous unescaped value
56+
return ValidateLargeContiguousValue<T, TValidationError>(utf8Bytes, provider, out result);
4357
}
4458

45-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
46-
private static TValidationError? ValidateFastPath<T, TValidationError>(
59+
// NoInlining: keeps the try/finally and ArrayPool machinery out of ValidateFromUtf8's inlined body,
60+
// ensuring the JIT emits compact native code for the hot stackalloc path.
61+
[MethodImpl(MethodImplOptions.NoInlining)]
62+
private static TValidationError? ValidateLargeContiguousValue<T, TValidationError>(
4763
ReadOnlySpan<byte> utf8Bytes,
4864
IFormatProvider? provider,
4965
out T? result)
5066
where T : IObjectFactory<T, ReadOnlySpan<char>, TValidationError>
5167
where TValidationError : class, IValidationError<TValidationError>
5268
{
53-
// UTF-16 char count is always <= UTF-8 byte count, so this comparison is safe
54-
if (utf8Bytes.Length <= _STACKALLOC_CHAR_THRESHOLD)
55-
{
56-
// Constant size enables JIT to emit a simple stack bump instead of localloc
57-
Span<char> charBuf = stackalloc char[_STACKALLOC_CHAR_THRESHOLD];
58-
var charsWritten = Encoding.UTF8.GetChars(utf8Bytes, charBuf);
59-
return T.Validate(charBuf[..charsWritten], provider, out result);
60-
}
61-
62-
// Large values: use array pool
6369
var rentedChars = ArrayPool<char>.Shared.Rent(utf8Bytes.Length);
6470

6571
try
@@ -73,19 +79,66 @@ public static class Utf8JsonReaderHelper
7379
}
7480
}
7581

76-
private static TValidationError? ValidateSlowPath<T, TValidationError>(
82+
// NoInlining: fragmented values are rare; isolating this keeps ValidateFromUtf8's
83+
// inlined body compact and avoids polluting the caller with ArrayPool<byte> machinery.
84+
[MethodImpl(MethodImplOptions.NoInlining)]
85+
private static TValidationError? ValidateFragmentedUnescaped<T, TValidationError>(
86+
ref Utf8JsonReader reader,
87+
IFormatProvider? provider,
88+
out T? result)
89+
where T : IObjectFactory<T, ReadOnlySpan<char>, TValidationError>
90+
where TValidationError : class, IValidationError<TValidationError>
91+
{
92+
var sequence = reader.ValueSequence;
93+
var byteLength = checked((int)sequence.Length);
94+
95+
var rentedBytes = ArrayPool<byte>.Shared.Rent(byteLength);
96+
97+
try
98+
{
99+
sequence.CopyTo(rentedBytes);
100+
var utf8Bytes = rentedBytes.AsSpan(0, byteLength);
101+
102+
// Now we have contiguous bytes — same transcoding logic as the contiguous unescaped path
103+
if (byteLength <= _STACKALLOC_CHAR_THRESHOLD)
104+
{
105+
Span<char> charBuf = stackalloc char[_STACKALLOC_CHAR_THRESHOLD];
106+
var charsWritten = Encoding.UTF8.GetChars(utf8Bytes, charBuf);
107+
return T.Validate(charBuf[..charsWritten], provider, out result);
108+
}
109+
110+
var rentedChars = ArrayPool<char>.Shared.Rent(byteLength);
111+
112+
try
113+
{
114+
var charsWritten = Encoding.UTF8.GetChars(utf8Bytes, rentedChars);
115+
return T.Validate(rentedChars.AsSpan(0, charsWritten), provider, out result);
116+
}
117+
finally
118+
{
119+
ArrayPool<char>.Shared.Return(rentedChars);
120+
}
121+
}
122+
finally
123+
{
124+
ArrayPool<byte>.Shared.Return(rentedBytes);
125+
}
126+
}
127+
128+
// NoInlining: escaped values are the rarest case; isolating this keeps the dispatch compact.
129+
[MethodImpl(MethodImplOptions.NoInlining)]
130+
private static TValidationError? ValidateEscaped<T, TValidationError>(
77131
ref Utf8JsonReader reader,
78132
IFormatProvider? provider,
79133
out T? result)
80134
where T : IObjectFactory<T, ReadOnlySpan<char>, TValidationError>
81135
where TValidationError : class, IValidationError<TValidationError>
82136
{
83-
// Get the byte length for buffer sizing
137+
// CopyString handles both unescaping and reassembly of fragmented sequences
84138
var byteLength = reader.HasValueSequence
85139
? checked((int)reader.ValueSequence.Length)
86140
: reader.ValueSpan.Length;
87141

88-
// Use CopyString for escaped/fragmented values
89142
if (byteLength <= _STACKALLOC_CHAR_THRESHOLD)
90143
{
91144
Span<char> charBuf = stackalloc char[_STACKALLOC_CHAR_THRESHOLD];

test/Thinktecture.Runtime.Extensions.Json.Tests/Internal/Utf8JsonReaderHelperTests.cs

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,26 @@ private static Utf8JsonReader CreateReader(string json)
129129
/// This ensures the fast path is taken (ValueIsEscaped = false).
130130
/// WARNING: The value must not contain characters that require JSON escaping (", \, control chars).
131131
/// </summary>
132-
private static Utf8JsonReader CreateUnescapedReader(string value)
132+
private static byte[] CreateUnescapedJsonBytes(string value)
133133
{
134134
// Build JSON bytes manually: quote + UTF-8(value) + quote
135135
var valueBytes = Encoding.UTF8.GetBytes(value);
136136
var jsonBytes = new byte[valueBytes.Length + 2];
137137
jsonBytes[0] = (byte)'"';
138138
valueBytes.CopyTo(jsonBytes, 1);
139139
jsonBytes[^1] = (byte)'"';
140-
return CreateReader(jsonBytes);
140+
return jsonBytes;
141+
}
142+
143+
/// <summary>
144+
/// Creates a Utf8JsonReader for a string value without JSON escaping non-ASCII characters.
145+
/// The value is wrapped in quotes and the raw UTF-8 bytes are used.
146+
/// This ensures the fast path is taken (ValueIsEscaped = false).
147+
/// WARNING: The value must not contain characters that require JSON escaping (", \, control chars).
148+
/// </summary>
149+
private static Utf8JsonReader CreateUnescapedReader(string value)
150+
{
151+
return CreateReader(CreateUnescapedJsonBytes(value));
141152
}
142153

143154
/// <summary>
@@ -897,6 +908,43 @@ public void Should_handle_fragmented_empty_string()
897908
result!.CapturedValue.Should().BeEmpty();
898909
}
899910

911+
[Theory]
912+
[InlineData(128)] // Exactly at _STACKALLOC_CHAR_THRESHOLD → stackalloc branch in ValidateFragmentedUnescaped
913+
[InlineData(129)] // Just over threshold → ArrayPool branch in ValidateFragmentedUnescaped
914+
public void Should_handle_fragmented_unescaped_at_stackalloc_boundary(int length)
915+
{
916+
var value = new string('F', length);
917+
var jsonBytes = CreateUnescapedJsonBytes(value);
918+
919+
var reader = CreateFragmentedReader(jsonBytes, 65);
920+
921+
var error = Utf8JsonReaderHelper.ValidateFromUtf8<SpanCapture, ValidationError>(
922+
ref reader, null, out var result);
923+
924+
error.Should().BeNull();
925+
result!.CapturedValue.Should().Be(value);
926+
}
927+
928+
[Fact]
929+
public void Should_handle_fragmented_unescaped_multibyte_at_128_byte_boundary()
930+
{
931+
// 64 × é (2 bytes each) = 128 bytes UTF-8, 64 chars UTF-16
932+
// Fragmented: hits stackalloc branch in ValidateFragmentedUnescaped
933+
var value = new string('é', 64);
934+
Encoding.UTF8.GetByteCount(value).Should().Be(128);
935+
936+
var jsonBytes = CreateUnescapedJsonBytes(value);
937+
938+
// Split in the middle of a 2-byte character
939+
var reader = CreateFragmentedReader(jsonBytes, 65);
940+
941+
var error = Utf8JsonReaderHelper.ValidateFromUtf8<SpanCapture, ValidationError>(
942+
ref reader, null, out var result);
943+
944+
error.Should().BeNull();
945+
result!.CapturedValue.Should().Be(value);
946+
}
947+
900948
// ==========================================================================
901949
// CONSISTENCY TESTS: Compare with reader.GetString()
902950
// Verify that ValidateFromUtf8 produces the same result as GetString()
@@ -1564,19 +1612,21 @@ public void Should_propagate_validation_error_in_slow_path()
15641612
result.Should().BeNull();
15651613
}
15661614

1567-
[Fact]
1568-
public void Should_propagate_validation_error_in_fragmented_path()
1615+
[Theory]
1616+
[InlineData(15)] // Small value (≤ 128 bytes) → stackalloc branch in ValidateFragmentedUnescaped
1617+
[InlineData(300)] // Large value (> 128 bytes) → ArrayPool branch in ValidateFragmentedUnescaped
1618+
public void Should_propagate_validation_error_in_fragmented_path(int length)
15691619
{
1570-
var json = "\"fragmented-test\"";
1571-
var jsonBytes = Encoding.UTF8.GetBytes(json);
1572-
var reader = CreateFragmentedReader(jsonBytes, 5);
1620+
var value = new string('D', length);
1621+
var reader = CreateFragmentedReader(CreateUnescapedJsonBytes(value), length / 2 + 1);
15731622

15741623
var error = Utf8JsonReaderHelper.ValidateFromUtf8<AlwaysFailsValidation, ValidationError>(
15751624
ref reader, null, out var result);
15761625

15771626
error.Should().NotBeNull();
1578-
error!.Message.Should().Contain("fragmented-test");
1627+
error!.Message.Should().Contain(value);
15791628
result.Should().BeNull();
15801629
}
1630+
15811631
}
15821632
#endif

0 commit comments

Comments
 (0)