Skip to content

Commit 13b2d7a

Browse files
brantburnettemilienbev
authored andcommitted
NCBC-3726: Apply misc perf optimizations to transcoders
Motivation ---------- Generally improve performance of transcoders. Modifications ------------- - Support encoding `ReadOnlyMemory<byte>` and `Memory<byte>` in `RawJsonTranscoder`, matching the behavior of `RawBinaryTranscoder`. - Support decoding `IMemoryOwner<byte>` in `RawJsonTranscoder`, matching the behavior of `RawBinaryTranscoder`. - Use `typeof(T) == ` instead `value is` checks for value types, which are elidable by JIT when it compiles variants for each value type used. - For both `RawJsonTranscoder` and `RawStringTranscoder` use a `StreamWriter` when encoding a large string rather than allocating a large buffer on the heap. - Move `Utf8NoBomEncoding` to a shared internal class. - Use `ThrowHelper`s for throwing exceptions in all transcoders. - Add a variety of missing unit tests and reorganize a bit. Results ------- - Significantly reduced heap allocations when encoding large strings using RawJsonTranscoder or RawStringTranscoder, especially if >1MB. However, we may want to consider support for encoding `Memory<char>` in the future as well to allow the consumer to reduce allocations of `string` objects on the heap. - The consumer may use `Memory<byte>`, `ReadOnlyMemory<byte>`, and `IMemoryOwner<byte>` to work with pooled buffers to reduce heap allocations when using `RawJsonTranscoder`. - Encoding with `Memory<byte>` and `ReadOnlyMemory<byte>` will have shorter JITed methods that are slightly more performant. - .NET 8 with PGO (Profile Guided Optimization) enabled (the default) may be able to perform guarded devirtualization and inline some of these calls since they now use `ThrowHelper` instead of throwing exceptions directly. Note: `LegacyTranscoder` could also be rewritten to use `typeof(T) ==` instead of `TypeCode`, and would see even more significant gains, but it seems to fragile to risk the change. Change-Id: I4c9c632ff820b47efc31ab0a2ae3f192b8dce766 Reviewed-on: https://review.couchbase.org/c/couchbase-net-client/+/207372 Reviewed-by: Emilien Bevierre <[email protected]> Tested-by: Build Bot <[email protected]>
1 parent c6119fa commit 13b2d7a

12 files changed

+483
-114
lines changed

src/Couchbase/Core/IO/Serializers/DefaultSerializer.cs

+1-3
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,6 @@ private static IContractResolver GetDefaultContractResolver()
9898

9999
#region Fields
100100

101-
private static readonly Encoding Utf8NoBomEncoding = new UTF8Encoding(false);
102-
103101
private JsonSerializerSettings _serializationSettings = null!;
104102
private JsonSerializerSettings _deserializationSettings = null!;
105103
private DeserializationOptions? _deserializationOptions;
@@ -242,7 +240,7 @@ public DeserializationOptions? DeserializationOptions
242240
/// <inheritdoc />
243241
public void Serialize(Stream stream, object? obj)
244242
{
245-
using (var sw = new StreamWriter(stream, Utf8NoBomEncoding, 1024, true))
243+
using (var sw = new StreamWriter(stream, EncodingUtils.Utf8NoBomEncoding, 1024, true))
246244
{
247245
using (var jr = new JsonTextWriter(sw)
248246
{

src/Couchbase/Core/IO/Transcoders/JsonTranscoder.cs

+19-13
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
using System;
22
using System.Diagnostics.CodeAnalysis;
33
using System.IO;
4-
using Couchbase.Core.Exceptions;
54
using Couchbase.Core.IO.Converters;
65
using Couchbase.Core.IO.Operations;
76
using Couchbase.Core.IO.Serializers;
7+
using Couchbase.Utils;
88

99
#nullable enable
1010

@@ -56,7 +56,8 @@ public override Flags GetFormat<T>(T value)
5656
dataFormat = DataFormat.Json;
5757
break;
5858
default:
59-
throw new ArgumentOutOfRangeException();
59+
ThrowHelper.ThrowArgumentOutOfRangeException();
60+
return default; //unreachable
6061
}
6162
return new Flags { Compression = Operations.Compression.None, DataFormat = dataFormat, TypeCode = typeCode };
6263
}
@@ -80,37 +81,42 @@ public override void Encode<T>(Stream stream, T value, Flags flags, OpCode opcod
8081
stream.Write(bytes, 0, bytes.Length);
8182
break;
8283
}
83-
throw new UnsupportedException("JsonTranscoder does not support byte arrays.");
84+
ThrowHelper.ThrowUnsupportedException("JsonTranscoder does not support byte arrays.");
8485
}
8586
else
8687
{
8788
var msg = $"The value of T does not match the DataFormat provided: {flags.DataFormat}";
88-
throw new ArgumentException(msg);
89+
ThrowHelper.ThrowArgumentException(msg, nameof(value));
8990
}
9091

92+
break;
93+
9194
default:
92-
throw new ArgumentOutOfRangeException();
95+
ThrowHelper.ThrowArgumentOutOfRangeException();
96+
break;
9397
}
9498
}
9599

96100
[return: MaybeNull]
97101
public override T Decode<T>(ReadOnlyMemory<byte> buffer, Flags flags, OpCode opcode)
98102
{
99-
var typeCode = Type.GetTypeCode(typeof(T));
100103
if (typeof(T) == typeof(byte[]))
101104
{
102-
if (opcode == OpCode.Append || opcode == OpCode.Prepend)
105+
if (opcode is OpCode.Append or OpCode.Prepend)
103106
{
104-
object value = DecodeBinary(buffer.Span);
105-
return (T) value;
107+
var value = DecodeBinary(buffer.Span);
108+
return (T)(object) value;
106109
}
107-
throw new UnsupportedException("JsonTranscoder does not support byte arrays.");
110+
111+
ThrowHelper.ThrowUnsupportedException("JsonTranscoder does not support byte arrays.");
112+
return default!; //unreachable
108113
}
114+
109115
//special case for some binary ops
110-
if (typeCode == TypeCode.UInt64 && (opcode == OpCode.Increment || opcode == OpCode.Decrement))
116+
if (typeof(T) == typeof(ulong) && opcode is OpCode.Increment or OpCode.Decrement)
111117
{
112-
object value = ByteConverter.ToUInt64(buffer.Span, true);
113-
return (T) value;
118+
var value = ByteConverter.ToUInt64(buffer.Span, true);
119+
return (T)(object)value;
114120
}
115121

116122
//everything else gets the JSON treatment

src/Couchbase/Core/IO/Transcoders/LegacyTranscoder.cs

+12-7
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public override Flags GetFormat<T>(T value)
3636
switch (typeCode)
3737
{
3838
case TypeCode.Object:
39-
if (typeof(T) == typeof(Byte[]))
39+
if (typeof(T) == typeof(byte[]))
4040
{
4141
dataFormat = DataFormat.Binary;
4242
}
@@ -61,7 +61,8 @@ public override Flags GetFormat<T>(T value)
6161
dataFormat = DataFormat.String;
6262
break;
6363
default:
64-
throw new ArgumentOutOfRangeException();
64+
ThrowHelper.ThrowArgumentOutOfRangeException();
65+
return default!; // unreachable
6566
}
6667
return new Flags() { Compression = Operations.Compression.None, DataFormat = dataFormat, TypeCode = typeCode };
6768
}
@@ -89,12 +90,13 @@ public override void Encode<T>(Stream stream, T value, Flags flags, OpCode opcod
8990
else
9091
{
9192
var msg = $"The value of T does not match the DataFormat provided: {flags.DataFormat}";
92-
throw new ArgumentException(msg);
93+
ThrowHelper.ThrowArgumentException(msg, nameof(value));
9394
}
9495
break;
9596

9697
default:
97-
throw new ArgumentOutOfRangeException();
98+
ThrowHelper.ThrowArgumentOutOfRangeException();
99+
break;
98100
}
99101
}
100102

@@ -194,7 +196,8 @@ public virtual void Encode<T>(Stream stream, T value, TypeCode typeCode, OpCode
194196
break;
195197

196198
default:
197-
throw new InvalidEnumArgumentException(nameof(typeCode), (int) typeCode, typeof(TypeCode));
199+
ThrowHelper.ThrowInvalidEnumArgumentException(nameof(typeCode), (int) typeCode, typeof(TypeCode));
200+
break;
198201
}
199202
}
200203

@@ -236,7 +239,8 @@ public override T Decode<T>(ReadOnlyMemory<byte> buffer, Flags flags, OpCode opc
236239
else
237240
{
238241
var msg = $"The value of T does not match the DataFormat provided: {flags.DataFormat}";
239-
throw new ArgumentException(msg);
242+
ThrowHelper.ThrowArgumentException(msg, nameof(value));
243+
return default!; //unreachable
240244
}
241245
break;
242246

@@ -357,7 +361,8 @@ public override T Decode<T>(ReadOnlyMemory<byte> buffer, Flags flags, OpCode opc
357361
break;
358362

359363
default:
360-
throw new ArgumentOutOfRangeException();
364+
ThrowHelper.ThrowArgumentOutOfRangeException();
365+
return default!; // unreachable
361366
}
362367
return (T?)value;
363368
}

src/Couchbase/Core/IO/Transcoders/RawBinaryTranscoder.cs

+13-9
Original file line numberDiff line numberDiff line change
@@ -32,28 +32,31 @@ public override Flags GetFormat<T>(T value)
3232
return new Flags { Compression = Operations.Compression.None, DataFormat = dataFormat, TypeCode = typeCode };
3333
}
3434

35-
throw new InvalidOperationException("The RawBinaryTranscoder only supports byte arrays, Memory<byte>, and ReadOnlyMemory<byte> as input.");
35+
ThrowHelper.ThrowInvalidOperationException("The RawBinaryTranscoder only supports byte arrays, Memory<byte>, and ReadOnlyMemory<byte> as input.");
36+
return default; // unreachable
3637
}
3738

3839
public override void Encode<T>(Stream stream, T value, Flags flags, OpCode opcode)
3940
{
40-
if(value is byte[] bytes)
41+
// For value types this typeof check approach allows eliding branches during JIT
42+
if (typeof(T) == typeof(Memory<byte>))
4143
{
42-
stream.Write(bytes, 0, bytes.Length);
44+
stream.Write((Memory<byte>)(object)value!);
4345
return;
4446
}
45-
if (value is Memory<byte> memory)
47+
if (typeof(T) == typeof(ReadOnlyMemory<byte>))
4648
{
47-
stream.Write(memory);
49+
stream.Write((ReadOnlyMemory<byte>)(object)value!);
4850
return;
4951
}
50-
if (value is ReadOnlyMemory<byte> readOnlyMemory)
52+
53+
if (value is byte[] bytes)
5154
{
52-
stream.Write(readOnlyMemory);
55+
stream.Write(bytes, 0, bytes.Length);
5356
return;
5457
}
5558

56-
throw new InvalidOperationException("The RawBinaryTranscoder can only encode byte arrays, Memory<byte>, and ReadOnlyMemory<byte>.");
59+
ThrowHelper.ThrowInvalidOperationException("The RawBinaryTranscoder can only encode byte arrays, Memory<byte>, and ReadOnlyMemory<byte>.");
5760
}
5861

5962
public override T Decode<T>(ReadOnlyMemory<byte> buffer, Flags flags, OpCode opcode)
@@ -90,7 +93,8 @@ public override T Decode<T>(ReadOnlyMemory<byte> buffer, Flags flags, OpCode opc
9093
}
9194
}
9295

93-
throw new InvalidOperationException("The RawBinaryTranscoder can only decode byte arrays or IMemoryOwner<byte>.");
96+
ThrowHelper.ThrowInvalidOperationException("The RawBinaryTranscoder can only decode byte arrays or IMemoryOwner<byte>.");
97+
return default!; // unreachable
9498
}
9599
}
96100
}

src/Couchbase/Core/IO/Transcoders/RawJsonTranscoder.cs

+77-6
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,49 @@
11
using System;
2+
using System.Buffers;
23
using System.Diagnostics.CodeAnalysis;
34
using System.IO;
5+
using Couchbase.Core.IO.Converters;
46
using Couchbase.Core.IO.Operations;
7+
using Couchbase.Utils;
58

69
#nullable enable
710

811
namespace Couchbase.Core.IO.Transcoders
912
{
1013
public class RawJsonTranscoder : BaseTranscoder
1114
{
15+
private const int BufferSize = 1024;
16+
1217
public override Flags GetFormat<T>(T value)
1318
{
1419
var typeCode = Type.GetTypeCode(typeof(T));
15-
if (typeof(T) == typeof(byte[]) || typeof(T) == typeof(string))
20+
if (typeof(T) == typeof(byte[]) ||
21+
typeof(T) == typeof(Memory<byte>) ||
22+
typeof(T) == typeof(ReadOnlyMemory<byte>) ||
23+
typeof(T) == typeof(string))
1624
{
1725
var dataFormat = DataFormat.Json;
1826
return new Flags { Compression = Operations.Compression.None, DataFormat = dataFormat, TypeCode = typeCode };
1927
}
2028

21-
throw new InvalidOperationException("The RawJsonTranscoder only supports byte arrays as input.");
29+
ThrowHelper.ThrowInvalidOperationException("The RawJsonTranscoder only supports byte arrays as input.");
30+
return default; // unreachable
2231
}
2332

2433
public override void Encode<T>(Stream stream, T value, Flags flags, OpCode opcode)
2534
{
35+
// For value types this typeof check approach allows eliding branches during JIT
36+
if (typeof(T) == typeof(Memory<byte>))
37+
{
38+
stream.Write((Memory<byte>)(object)value!);
39+
return;
40+
}
41+
if (typeof(T) == typeof(ReadOnlyMemory<byte>))
42+
{
43+
stream.Write((ReadOnlyMemory<byte>)(object)value!);
44+
return;
45+
}
46+
2647
if (value is byte[] bytes)
2748
{
2849
stream.Write(bytes, 0, bytes.Length);
@@ -31,12 +52,34 @@ public override void Encode<T>(Stream stream, T value, Flags flags, OpCode opcod
3152

3253
if (value is string strValue)
3354
{
34-
var strBytes = System.Text.Encoding.UTF8.GetBytes(strValue);
35-
stream.Write(strBytes,0, strBytes.Length);
55+
if (strValue.Length <= BufferSize)
56+
{
57+
// For small strings (less than the buffer size), it is more efficient to avoid the cost of allocating the buffers
58+
// within a StreamWriter and serialize directly to a pooled buffer instead.
59+
60+
var buffer = ArrayPool<byte>.Shared.Rent(ByteConverter.GetStringByteCount(strValue));
61+
try
62+
{
63+
var length = ByteConverter.FromString(strValue, buffer.AsSpan());
64+
stream.Write(buffer, 0, length);
65+
}
66+
finally
67+
{
68+
ArrayPool<byte>.Shared.Return(buffer);
69+
}
70+
}
71+
else
72+
{
73+
// For larger strings, use a StreamWriter to serialize the stream in blocks to avoid allocating a very large buffer.
74+
75+
using var writer = new StreamWriter(stream, EncodingUtils.Utf8NoBomEncoding, BufferSize, leaveOpen: true);
76+
writer.Write(strValue);
77+
}
78+
3679
return;
3780
}
3881

39-
throw new InvalidOperationException("The RawJsonTranscoder can only encode JSON byte arrays.");
82+
ThrowHelper.ThrowInvalidOperationException("The RawJsonTranscoder can only encode JSON byte arrays.");
4083
}
4184

4285
[return: MaybeNull]
@@ -49,12 +92,40 @@ public override T Decode<T>(ReadOnlyMemory<byte> buffer, Flags flags, OpCode opc
4992
return (T)value;
5093
}
5194

95+
if (typeof(T) == typeof(IMemoryOwner<byte>))
96+
{
97+
// Note: it is important for the consumer to dispose of the returned IMemoryOwner<byte>, in keeping
98+
// with IMemoryOwner<T> conventions. Failure to properly dispose this object will result in the memory
99+
// not being returned to the pool, which will increase GC impact across various parts of the framework.
100+
101+
#if NET6_0_OR_GREATER
102+
var memoryOwner = MemoryPool<byte>.Shared.RentAndSlice(buffer.Length);
103+
#else
104+
var memoryOwner = OperationResponseMemoryPool.Instance.RentAndSlice(buffer.Length);
105+
#endif
106+
try
107+
{
108+
buffer.CopyTo(memoryOwner.Memory);
109+
110+
// This boxes the SlicedMemoryOwner on the heap, making it act like a class to the consumer
111+
return (T)(object)memoryOwner;
112+
}
113+
catch
114+
{
115+
// Cleanup if the copy fails
116+
memoryOwner.Dispose();
117+
throw;
118+
}
119+
}
120+
52121
if (targetType == typeof(string))
53122
{
54123
object? value = DecodeString(buffer.Span);
55124
return (T?) value;
56125
}
57-
throw new InvalidOperationException("The RawJsonTranscoder can only decode JSON byte arrays.");
126+
127+
ThrowHelper.ThrowInvalidOperationException("The RawJsonTranscoder can only decode JSON byte arrays.");
128+
return default!; // unreachable
58129
}
59130
}
60131
}

0 commit comments

Comments
 (0)