Skip to content

Commit 1a32230

Browse files
Astnclaude
andcommitted
Perf pass from the Astra review: direct-call invokers, local-state tokenizer, sealing, dead code
Priority 1, throughput. Measured A/B in one session with --sync 2 1 (seven runs): 3.2-4.1 M RPC/s (median 3.6 M) before, 4.0-4.8 M (median 4.4 M) after, on a machine about 15% slower than the README tables. 745/745 tests on net8.0 and net10.0 after every step; wire output unchanged. - RpcMethod compiles a third invoker for the built-in serializer that reads parameters through static helpers on JsmnRequestReader and writes results through Utf8Json overloads on the concrete PooledByteBufferWriter. Expression trees compile to dynamic methods, which get no tiered PGO, so every virtual, delegate and interface call inside them was a real indirect call. - JsmnTokenizer.Parse keeps position, token count, parent, depth and the arrays in locals and publishes them once; the string/primitive/bare-key scanners are static and return an end index; growth is a cold method. Largest single gain. - Handler caches the last session hit per thread by reference (P4); the reader selects envelope keys by length (P5); Utf8KeyTable is one immutable snapshot of bucket heads into a contiguous entry array (P6); dictionary Add is compiled (P8); ordered named params use the identity map (P9); Utf8Json.Hash consumes eight bytes per step; strings over 512 chars are written in chunks; a nested scratch returns its buffers. - Sealed Handler and JsonRpcContext; static readonly session registry and default id; readonly RpcParameter.NameUtf8 and JsonRpcStateAsync fields; JsmnToken packs its byte fields first (24 -> 20 bytes). - Removed Basic.cs, EmptyBatchError, Utf8KeyTable.ReplaceWith/Count, TypePlan.Type, the always-true Where, MakeMember's owner argument, PrintFinalIterationStats, unused usings and comments that restated the code. - --sync takes an optional thread count (--sync 2 1 runs the one-thread row). - docs/reviews: the review itself plus an implementation record; the tokenizer header records the upstream jsmn state (unchanged since 2021) and the open PRs that were considered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent ccce23c commit 1a32230

20 files changed

Lines changed: 1408 additions & 373 deletions

‎Json-Rpc/Basic.cs‎

Lines changed: 0 additions & 26 deletions
This file was deleted.

‎Json-Rpc/Client/InProcessJsonRpcClient.cs‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
using System;
2-
using System.Collections.Generic;
3-
using System.Linq;
4-
using System.Text;
5-
using System.Threading;
62
using System.Threading.Tasks;
73

84
namespace AustinHarris.JsonRpc.Client

‎Json-Rpc/Handler.cs‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,33 @@ namespace AustinHarris.JsonRpc
66
using System.Reflection;
77
using System.Threading;
88
using AustinHarris.JsonRpc.Invocation;
9+
using AustinHarris.JsonRpc.Jsmn;
910
using AustinHarris.JsonRpc.Serialization;
1011
using NonBlocking;
1112

12-
public class Handler
13+
public sealed class Handler
1314
{
1415
#region Members
1516
private static int _sessionHandlerMasterVersion = 1;
1617
[ThreadStatic]
1718
private static Dictionary<string, Handler> _sessionHandlersLocal;
1819
[ThreadStatic]
1920
private static int _sessionHandlerLocalVersion = 0;
20-
private static ConcurrentDictionary<string, Handler> _sessionHandlersMaster;
21+
// The last hit on this thread: a transport hands the same session-id string instance to every request, so
22+
// one reference comparison replaces hashing a GUID-length string. Dropped with the local snapshot.
23+
[ThreadStatic]
24+
private static string _lastSessionId;
25+
[ThreadStatic]
26+
private static Handler _lastSessionHandler;
27+
private static readonly ConcurrentDictionary<string, Handler> _sessionHandlersMaster = new ConcurrentDictionary<string, Handler>();
2128

22-
private static volatile string _defaultSessionId;
29+
private static readonly string _defaultSessionId = Guid.NewGuid().ToString();
2330
#endregion
2431

2532
#region Constructors
2633

2734
static Handler()
2835
{
29-
_defaultSessionId = Guid.NewGuid().ToString();
30-
_sessionHandlersMaster = new ConcurrentDictionary<string, Handler>();
3136
_sessionHandlersMaster[_defaultSessionId] = new Handler(_defaultSessionId);
3237
}
3338

@@ -56,9 +61,17 @@ public static Handler GetSessionHandler(string sessionId)
5661
{
5762
_sessionHandlersLocal = new Dictionary<string, Handler>(_sessionHandlersMaster);
5863
_sessionHandlerLocalVersion = _sessionHandlerMasterVersion;
64+
_lastSessionId = null;
65+
_lastSessionHandler = null;
66+
}
67+
else if (ReferenceEquals(sessionId, _lastSessionId))
68+
{
69+
return _lastSessionHandler;
5970
}
6071
if (_sessionHandlersLocal.TryGetValue(sessionId, out var local))
6172
{
73+
_lastSessionId = sessionId;
74+
_lastSessionHandler = local;
6275
return local;
6376
}
6477
Interlocked.Increment(ref _sessionHandlerMasterVersion);
@@ -355,7 +368,11 @@ internal bool HandleRequest(JsonRpcRequestReader reader, int index, JsonRpcSeria
355368
output.Write(ResultPrefix);
356369
try
357370
{
358-
method.Invoke(reader, map, serializer, output);
371+
// The built-in serializer's requests take the invoker compiled against the concrete reader and
372+
// writer: typed reads straight from the tokens and formatted writes into the pooled buffer, with
373+
// no virtual, delegate or interface call in between. Everything else goes through the serializer.
374+
if (reader is JsmnRequestReader jsmn && jsmn.IsBuiltIn) method.InvokeJsmn(jsmn, map, output);
375+
else method.Invoke(reader, map, serializer, output);
359376
}
360377
catch (Exception ex)
361378
{
@@ -571,6 +588,13 @@ private static int[] BindMap(JsonRpcRequestReader reader, RpcMethod method, out
571588
}
572589
else
573590
{
591+
if (given == expected && method.HasUniqueNames)
592+
{
593+
// names supplied in declaration order: the common case for generated clients, and the identity map
594+
int p = 0;
595+
while (p < expected && reader.ParamNameUtf8(p).SequenceEqual(parameters[p].NameUtf8)) p++;
596+
if (p == expected) return method.IdentityMap;
597+
}
574598
var map = ArrayPool<int>.Shared.Rent(Math.Max(1, expected));
575599
int matched = 0;
576600
for (int p = 0; p < expected; p++)

‎Json-Rpc/Invocation/RpcMethod.cs‎

Lines changed: 123 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Linq;
55
using System.Linq.Expressions;
66
using System.Reflection;
7+
using AustinHarris.JsonRpc.Jsmn;
78
using AustinHarris.JsonRpc.Serialization;
89

910
namespace AustinHarris.JsonRpc.Invocation
@@ -18,13 +19,29 @@ namespace AustinHarris.JsonRpc.Invocation
1819
/// <summary>Same binding, but returns the boxed result. Used when pre/post handlers need a <see cref="JsonResponse"/>.</summary>
1920
public delegate object BoxedInvoker(JsonRpcRequestReader reader, int[] map);
2021

22+
/// <summary>
23+
/// The invoker specialised for the built-in serializer: parameters are read from the tokens by direct static
24+
/// calls and the result is formatted into the concrete pooled writer, so a primitive request goes from bytes to
25+
/// bytes without a virtual, delegate or interface call around the service method.
26+
/// </summary>
27+
internal delegate void JsmnInvoker(JsmnRequestReader reader, int[] map, PooledByteBufferWriter output);
28+
2129
public sealed class RpcParameter
2230
{
23-
public string Name { get; internal set; }
24-
public Type Type { get; internal set; }
25-
public bool HasDefault { get; internal set; }
26-
public object DefaultValue { get; internal set; }
27-
internal byte[] NameUtf8;
31+
internal RpcParameter(string name, Type type, bool hasDefault, object defaultValue)
32+
{
33+
Name = name;
34+
Type = type;
35+
HasDefault = hasDefault;
36+
DefaultValue = defaultValue;
37+
NameUtf8 = System.Text.Encoding.UTF8.GetBytes(name);
38+
}
39+
40+
public string Name { get; }
41+
public Type Type { get; }
42+
public bool HasDefault { get; }
43+
public object DefaultValue { get; }
44+
internal readonly byte[] NameUtf8;
2845
}
2946

3047
/// <summary>
@@ -40,8 +57,11 @@ public sealed class RpcMethod
4057
public int DefaultCount { get; private set; }
4158
public StreamingInvoker Invoke { get; private set; }
4259
public BoxedInvoker InvokeBoxed { get; private set; }
60+
internal JsmnInvoker InvokeJsmn { get; private set; }
4361
/// <summary>The identity map (0,1,2,...) used when positional params match exactly.</summary>
4462
internal int[] IdentityMap { get; private set; }
63+
/// <summary>True when no two parameters share a JSON name (named params in declaration order can then use <see cref="IdentityMap"/>).</summary>
64+
internal bool HasUniqueNames { get; private set; }
4565

4666
private static readonly MethodInfo ReadParamGeneric = typeof(JsonRpcRequestReader)
4767
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
@@ -51,7 +71,34 @@ public sealed class RpcMethod
5171
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
5272
.First(m => m.Name == nameof(JsonRpcSerializer.Write) && m.IsGenericMethodDefinition);
5373

54-
private static readonly MethodInfo WriteNullMethod = typeof(Utf8Json).GetMethod(nameof(Utf8Json.WriteNull));
74+
private static readonly MethodInfo WriteNullMethod = typeof(Utf8Json).GetMethod(nameof(Utf8Json.WriteNull), new[] { typeof(IBufferWriter<byte>) });
75+
private static readonly MethodInfo WriteNullPooled = typeof(Utf8Json).GetMethod(nameof(Utf8Json.WriteNull), new[] { typeof(PooledByteBufferWriter) });
76+
private static readonly MethodInfo ReadTypedParamGeneric = typeof(JsmnRequestReader).GetMethod(nameof(JsmnRequestReader.ReadTypedParam), BindingFlags.NonPublic | BindingFlags.Static);
77+
private static readonly MethodInfo ParamIsNullLiteral = typeof(JsmnRequestReader).GetMethod(nameof(JsmnRequestReader.ParamIsNullLiteral), BindingFlags.NonPublic | BindingFlags.Static);
78+
private static readonly MethodInfo WriteValuePooledGeneric = typeof(RpcMethod).GetMethod(nameof(WriteValuePooled), BindingFlags.NonPublic | BindingFlags.Static);
79+
80+
/// <summary>The built-in typed writer for a non-primitive result (POCO, collection, nullable of a non-primitive).</summary>
81+
private static void WriteValuePooled<T>(PooledByteBufferWriter output, T value) => JsmnWriter<T>.Write(output, value);
82+
83+
// primitive type -> (static reader on JsmnRequestReader, static writer on Utf8Json taking the pooled writer)
84+
private static readonly Dictionary<Type, (MethodInfo Read, MethodInfo Write)> Primitives = BuildPrimitiveTable();
85+
86+
private static Dictionary<Type, (MethodInfo, MethodInfo)> BuildPrimitiveTable()
87+
{
88+
var reader = typeof(JsmnRequestReader);
89+
MethodInfo Read(string name) => reader.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static);
90+
MethodInfo Write(string name, Type t) => typeof(Utf8Json).GetMethod(name, new[] { typeof(PooledByteBufferWriter), t });
91+
return new Dictionary<Type, (MethodInfo, MethodInfo)>
92+
{
93+
[typeof(string)] = (Read(nameof(JsmnRequestReader.ReadStringParam)), Write(nameof(Utf8Json.WriteString), typeof(string))),
94+
[typeof(int)] = (Read(nameof(JsmnRequestReader.ReadInt32Param)), Write(nameof(Utf8Json.WriteInt64), typeof(long))),
95+
[typeof(long)] = (Read(nameof(JsmnRequestReader.ReadInt64Param)), Write(nameof(Utf8Json.WriteInt64), typeof(long))),
96+
[typeof(double)] = (Read(nameof(JsmnRequestReader.ReadDoubleParam)), Write(nameof(Utf8Json.WriteDouble), typeof(double))),
97+
[typeof(float)] = (Read(nameof(JsmnRequestReader.ReadSingleParam)), Write(nameof(Utf8Json.WriteSingle), typeof(float))),
98+
[typeof(bool)] = (Read(nameof(JsmnRequestReader.ReadBooleanParam)), Write(nameof(Utf8Json.WriteBool), typeof(bool))),
99+
[typeof(decimal)] = (Read(nameof(JsmnRequestReader.ReadDecimalParam)), Write(nameof(Utf8Json.WriteDecimal), typeof(decimal))),
100+
};
101+
}
55102

56103
/// <summary>Builds the invokers for an instance (or static) method; <paramref name="parameterNames"/> are the JSON names (null = CLR names).</summary>
57104
public static RpcMethod FromMethod(string name, MethodInfo method, object target, string[] parameterNames = null)
@@ -104,8 +151,13 @@ private static RpcMethod Build(string name, ParameterInfo[] ps, Type returnType,
104151
bool expectsRef = ps.Length > 0 && ps[ps.Length - 1].ParameterType == typeof(JsonRpcException).MakeByRefType();
105152
int bindable = expectsRef ? ps.Length - 1 : ps.Length;
106153

154+
// the built-in invoker binds the same parameters through direct static reads
155+
var jsmnReader = Expression.Parameter(typeof(JsmnRequestReader), "reader");
156+
var pooled = Expression.Parameter(typeof(PooledByteBufferWriter), "output");
157+
107158
var parameters = new RpcParameter[bindable];
108159
var args = new Expression[ps.Length];
160+
var jsmnArgs = new Expression[ps.Length];
109161
int defaultCount = 0;
110162
for (int i = 0; i < bindable; i++)
111163
{
@@ -117,59 +169,63 @@ private static RpcMethod Build(string name, ParameterInfo[] ps, Type returnType,
117169
else if (p.IsOptional && p.DefaultValue != DBNull.Value && p.DefaultValue != Type.Missing) defaultValue = p.DefaultValue;
118170
if (hasDefault) defaultCount++;
119171

120-
parameters[i] = new RpcParameter
121-
{
122-
Name = jsonName,
123-
Type = p.ParameterType,
124-
HasDefault = hasDefault,
125-
DefaultValue = defaultValue,
126-
NameUtf8 = System.Text.Encoding.UTF8.GetBytes(jsonName)
127-
};
172+
parameters[i] = new RpcParameter(jsonName, p.ParameterType, hasDefault, defaultValue);
128173

129174
var index = Expression.ArrayIndex(map, Expression.Constant(i));
130175
var read = Expression.Call(reader, ReadParamGeneric.MakeGenericMethod(p.ParameterType), index);
131176
Expression fallback = MakeDefault(p.ParameterType, hasDefault, defaultValue);
132-
args[i] = Expression.Condition(Expression.GreaterThanOrEqual(index, Expression.Constant(0)), read, fallback);
177+
var supplied = Expression.GreaterThanOrEqual(index, Expression.Constant(0));
178+
args[i] = Expression.Condition(supplied, read, fallback);
179+
jsmnArgs[i] = Expression.Condition(supplied, MakeJsmnRead(jsmnReader, index, p.ParameterType), fallback);
133180
}
134-
if (expectsRef) args[ps.Length - 1] = refEx;
181+
if (expectsRef) args[ps.Length - 1] = jsmnArgs[ps.Length - 1] = refEx;
135182

136183
var call = makeCall(args);
184+
var jsmnCall = makeCall(jsmnArgs);
137185
var throwIfRef = expectsRef
138186
? (Expression)Expression.IfThen(Expression.NotEqual(refEx, Expression.Constant(null, typeof(JsonRpcException))), Expression.Throw(refEx))
139187
: Expression.Empty();
188+
var clearRef = Expression.Assign(refEx, Expression.Constant(null, typeof(JsonRpcException)));
140189

141190
// streaming: result = call(); if (refEx != null) throw; serializer.Write<TRet>(output, result)
142191
Expression streamingBody;
143192
Expression boxedBody;
193+
Expression jsmnBody;
144194
if (returnType == typeof(void))
145195
{
146-
streamingBody = Expression.Block(new[] { refEx },
147-
Expression.Assign(refEx, Expression.Constant(null, typeof(JsonRpcException))),
148-
call, throwIfRef, Expression.Call(WriteNullMethod, output));
149-
boxedBody = Expression.Block(typeof(object), new[] { refEx },
150-
Expression.Assign(refEx, Expression.Constant(null, typeof(JsonRpcException))),
151-
call, throwIfRef, Expression.Constant(null, typeof(object)));
196+
streamingBody = Expression.Block(new[] { refEx }, clearRef, call, throwIfRef, Expression.Call(WriteNullMethod, output));
197+
boxedBody = Expression.Block(typeof(object), new[] { refEx }, clearRef, call, throwIfRef, Expression.Constant(null, typeof(object)));
198+
jsmnBody = Expression.Block(new[] { refEx }, clearRef, jsmnCall, throwIfRef, Expression.Call(WriteNullPooled, pooled));
152199
}
153200
else
154201
{
155202
var result = Expression.Variable(returnType, "result");
156203
streamingBody = Expression.Block(new[] { refEx, result },
157-
Expression.Assign(refEx, Expression.Constant(null, typeof(JsonRpcException))),
204+
clearRef,
158205
Expression.Assign(result, call),
159206
throwIfRef,
160207
Expression.Call(serializer, WriteGeneric.MakeGenericMethod(returnType), output, result));
161208
boxedBody = Expression.Block(typeof(object), new[] { refEx, result },
162-
Expression.Assign(refEx, Expression.Constant(null, typeof(JsonRpcException))),
209+
clearRef,
163210
Expression.Assign(result, call),
164211
throwIfRef,
165212
Expression.Convert(result, typeof(object)));
213+
jsmnBody = Expression.Block(new[] { refEx, result },
214+
clearRef,
215+
Expression.Assign(result, jsmnCall),
216+
throwIfRef,
217+
MakeJsmnWrite(pooled, result, returnType));
166218
}
167219

168220
var streaming = Expression.Lambda<StreamingInvoker>(streamingBody, reader, map, serializer, output).Compile();
169221
var boxed = Expression.Lambda<BoxedInvoker>(boxedBody, reader, map).Compile();
222+
var jsmnInvoker = Expression.Lambda<JsmnInvoker>(jsmnBody, jsmnReader, map, pooled).Compile();
170223

171224
var identity = new int[bindable];
172225
for (int i = 0; i < bindable; i++) identity[i] = i;
226+
var names = new HashSet<string>();
227+
bool unique = true;
228+
foreach (var parameter in parameters) unique &= names.Add(parameter.Name);
173229

174230
return new RpcMethod
175231
{
@@ -180,10 +236,52 @@ private static RpcMethod Build(string name, ParameterInfo[] ps, Type returnType,
180236
DefaultCount = defaultCount,
181237
Invoke = streaming,
182238
InvokeBoxed = boxed,
183-
IdentityMap = identity
239+
InvokeJsmn = jsmnInvoker,
240+
IdentityMap = identity,
241+
HasUniqueNames = unique
184242
};
185243
}
186244

245+
/// <summary>
246+
/// The built-in read of parameter <paramref name="index"/> as <paramref name="type"/>: a direct static call
247+
/// for the primitives, a null test plus the value read for their nullables, the typed reader cache for the rest.
248+
/// </summary>
249+
private static Expression MakeJsmnRead(ParameterExpression reader, Expression index, Type type)
250+
{
251+
if (Primitives.TryGetValue(type, out var primitive)) return Expression.Call(primitive.Read, reader, index);
252+
var underlying = Nullable.GetUnderlyingType(type);
253+
if (underlying != null && Primitives.TryGetValue(underlying, out var inner))
254+
{
255+
return Expression.Condition(
256+
Expression.Call(ParamIsNullLiteral, reader, index),
257+
Expression.Constant(null, type),
258+
Expression.Convert(Expression.Call(inner.Read, reader, index), type));
259+
}
260+
return Expression.Call(ReadTypedParamGeneric.MakeGenericMethod(type), reader, index);
261+
}
262+
263+
/// <summary>The built-in write of <paramref name="result"/>: the concrete-writer formatter for primitives (and their nullables), the typed writer cache otherwise.</summary>
264+
private static Expression MakeJsmnWrite(ParameterExpression output, ParameterExpression result, Type type)
265+
{
266+
if (Primitives.TryGetValue(type, out var primitive))
267+
{
268+
Expression value = result;
269+
if (type == typeof(int)) value = Expression.Convert(result, typeof(long));
270+
return Expression.Call(primitive.Write, output, value);
271+
}
272+
var underlying = Nullable.GetUnderlyingType(type);
273+
if (underlying != null && Primitives.TryGetValue(underlying, out var inner))
274+
{
275+
Expression value = Expression.Property(result, "Value");
276+
if (underlying == typeof(int)) value = Expression.Convert(value, typeof(long));
277+
return Expression.IfThenElse(
278+
Expression.Property(result, "HasValue"),
279+
Expression.Call(inner.Write, output, value),
280+
Expression.Call(WriteNullPooled, output));
281+
}
282+
return Expression.Call(WriteValuePooledGeneric.MakeGenericMethod(type), output, result);
283+
}
284+
187285
private static Expression MakeDefault(Type type, bool hasDefault, object value)
188286
{
189287
if (!hasDefault || value == null) return Expression.Default(type);

0 commit comments

Comments
 (0)