Skip to content

Commit c788ba8

Browse files
FrayxRulezclaude
andcommitted
Generate a second set of parsers that read through TdJsonReader
SchemaGenerator now emits FromPtr_* alongside FromJson_*, from the same schema so the two cannot drift. Opt-in via <TdPointerParser>true</TdPointerParser> and a matching CompilerVisibleProperty; with it off the generated file is byte for byte what it was, which is what makes changing the generator safe when the app itself cannot be built here. Fields dispatch on name length then an exact compare rather than CRC32 - few enough per class for that to be cheap, and an unknown field cannot collide with a known one. @type keeps the hash, because eighty constructors is the wrong shape for a compare chain; TdJsonReader.ValueCrc32 walks raw memory and has to agree with the values the generator bakes in. Full parse on .NET Native, same payloads and the same objects out: updateUserStatus 682.2 ns -> 335.6 ns 2.0x updateFile 2.78 us -> 1.12 us 2.5x updateNewMessage 8.15 us -> 3.76 us 2.2x messages x50 436.3 us -> 181.7 us 2.4x updateOption 1.41 us -> 493.2 ns 2.9x Allocation is identical to the byte, so the parser stays copy- and allocation-free. On the desktop JIT the two are at parity. Validation runs the same assertions over both readers on every host and reports ok including on .NET Native, so the pointer parsers agree with the netstandard2.0 System.Text.Json ones across nested objects, vectors, abstract dispatch, escapes and unknown fields. The deep field-by-field comparison needs reflection and stays on the desktop. Two harness bugs fixed on the way: the shared validation had grown a reflective comparison that .NET Native has no metadata for, and UwpHost called it outside its try block - so it threw and the app sat on "running..." with no report and no clue why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a612ce5 commit c788ba8

10 files changed

Lines changed: 749 additions & 41 deletions

File tree

‎Telegram.Benchmarks.NetNative/Telegram.Benchmarks.NetNative.csproj‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
<GenerateAppInstallerFile>False</GenerateAppInstallerFile>
3434
<ProjectPriIndexName>Telegram.Benchmarks.NetNative</ProjectPriIndexName>
3535
<Nullable>enable</Nullable>
36+
<TdPointerParser>true</TdPointerParser>
3637
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
3738
<NoWarn>$(NoWarn);CS0169;CS0649;CS8618;CS8601;CS8603;CS8604;CS8625;2008</NoWarn>
3839
</PropertyGroup>
@@ -77,6 +78,7 @@
7778
<Name>Telegram.Generators</Name>
7879
</ProjectReference>
7980
<AdditionalFiles Include="..\Libraries\tdjson\td_api.tl" />
81+
<CompilerVisibleProperty Include="TdPointerParser" />
8082
</ItemGroup>
8183

8284
<ItemGroup>
@@ -90,6 +92,7 @@
9092
<Compile Include="..\Telegram.Benchmarks\Suite.cs" Link="Linked\Suite.cs" />
9193
<Compile Include="..\Telegram.Benchmarks\Validation.cs" Link="Linked\Validation.cs" />
9294
<Compile Include="..\Telegram.Benchmarks\Json\TdJsonReader.cs" Link="Linked\TdJsonReader.cs" />
95+
<Compile Include="..\Telegram.Benchmarks\Json\PtrClientJson.cs" Link="Linked\PtrClientJson.cs" />
9396
<Compile Include="..\Telegram.Benchmarks\Json\PointerParsers.cs" Link="Linked\PointerParsers.cs" />
9497
<Compile Include="..\Telegram.Benchmarks\Support.cs" Link="Linked\Support.cs" />
9598
<Compile Include="..\Telegram.Benchmarks\Uwp\UwpHost.cs" Link="Linked\UwpHost.cs" />
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
//
2+
// Copyright (c) Fela Ameghino 2015-2026
3+
//
4+
// Distributed under the GNU General Public License v3.0. (See accompanying
5+
// file LICENSE or copy at https://www.gnu.org/licenses/gpl-3.0.txt)
6+
//
7+
8+
using System;
9+
using System.Collections.Generic;
10+
using System.Runtime.CompilerServices;
11+
using System.Text.Json;
12+
using Telegram.Td;
13+
14+
#nullable disable
15+
16+
namespace Telegram.Td.Api
17+
{
18+
/// <summary>
19+
/// What the generated FromPtr_* parsers call, mirroring the Utf8JsonReader helpers in
20+
/// ClientJson.cs one for one. Lives beside the benchmark for now; when the app adopts the
21+
/// pointer path this file moves to Telegram/Td/ unchanged.
22+
/// </summary>
23+
public partial class ClientJson
24+
{
25+
/// <summary>
26+
/// The same table ClientJson hashes property names with, reached through the partial rather
27+
/// than copied - the generated switches only work if both sides compute identical values.
28+
/// </summary>
29+
/// A property rather than a field: static field initializers run in the order the compiler
30+
/// happens to put the partial's files in, and as a field this read null.
31+
internal static uint[] Crc32Table => crc32_table;
32+
33+
public delegate T PtrParser<T>(ref TdJsonReader reader, ClientResultHandler handler);
34+
35+
public delegate T PtrDispatch<T>(ref TdJsonReader reader, ClientResultHandler handler, uint hash);
36+
37+
/// <summary>Entry point: the whole payload, straight off td_receive's pointer.</summary>
38+
public static unsafe Object FromPtr(byte* buffer, int length, ClientResultHandler handler = null)
39+
{
40+
var reader = new TdJsonReader(buffer, length);
41+
reader.Read();
42+
43+
if (reader.TokenType == JsonTokenType.StartObject)
44+
{
45+
reader.Read();
46+
47+
if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("@type"u8))
48+
{
49+
reader.Read();
50+
var hash = reader.ValueCrc32();
51+
52+
reader.Read();
53+
return DoFromPtr(ref reader, handler, hash);
54+
}
55+
}
56+
57+
return new Error(400, "Can't deserialize");
58+
}
59+
60+
/// <summary>
61+
/// Reads a nested object of an abstract type: its @type decides which parser runs.
62+
/// </summary>
63+
public static T FromPtr<T>(ref TdJsonReader reader, ClientResultHandler handler, PtrDispatch<T> dispatch) where T : Object
64+
{
65+
if (reader.TokenType == JsonTokenType.Null)
66+
{
67+
return null;
68+
}
69+
70+
reader.Read();
71+
72+
if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("@type"u8))
73+
{
74+
reader.Read();
75+
var hash = reader.ValueCrc32();
76+
77+
reader.Read();
78+
return dispatch(ref reader, handler, hash);
79+
}
80+
81+
return null;
82+
}
83+
84+
/// <summary>
85+
/// Steps onto the first field of an object, past the @type the sender always writes.
86+
/// </summary>
87+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
88+
public static void ReadStartObjectPtr(ref TdJsonReader reader)
89+
{
90+
if (reader.TokenType == JsonTokenType.Null)
91+
{
92+
return;
93+
}
94+
95+
if (reader.TokenType == JsonTokenType.StartObject)
96+
{
97+
reader.Read();
98+
99+
if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("@type"u8))
100+
{
101+
reader.Read();
102+
reader.Read();
103+
}
104+
}
105+
}
106+
107+
public static List<T> GetObjectArrayPtr<T>(ref TdJsonReader reader, ClientResultHandler handler, PtrParser<T> parser) where T : Object
108+
{
109+
var items = new List<T>();
110+
111+
reader.Read();
112+
113+
while (reader.TokenType == JsonTokenType.StartObject || reader.TokenType == JsonTokenType.Null)
114+
{
115+
items.Add(reader.TokenType == JsonTokenType.Null ? null : parser(ref reader, handler));
116+
reader.Read();
117+
}
118+
119+
return items;
120+
}
121+
122+
public static List<IList<T>> GetObjectArrayArrayPtr<T>(ref TdJsonReader reader, ClientResultHandler handler, PtrParser<T> parser) where T : Object
123+
{
124+
var items = new List<IList<T>>();
125+
126+
reader.Read();
127+
128+
while (reader.TokenType == JsonTokenType.StartArray)
129+
{
130+
items.Add(GetObjectArrayPtr(ref reader, handler, parser));
131+
reader.Read();
132+
}
133+
134+
return items;
135+
}
136+
137+
public static List<bool> GetBooleanArrayPtr(ref TdJsonReader reader)
138+
{
139+
var items = new List<bool>();
140+
141+
reader.Read();
142+
while (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False)
143+
{
144+
items.Add(reader.GetBoolean());
145+
reader.Read();
146+
}
147+
148+
return items;
149+
}
150+
151+
public static List<int> GetInt32ArrayPtr(ref TdJsonReader reader)
152+
{
153+
var items = new List<int>();
154+
155+
reader.Read();
156+
while (reader.TokenType == JsonTokenType.Number)
157+
{
158+
items.Add(reader.GetInt32());
159+
reader.Read();
160+
}
161+
162+
return items;
163+
}
164+
165+
public static List<long> GetInt64ArrayPtr(ref TdJsonReader reader)
166+
{
167+
var items = new List<long>();
168+
169+
reader.Read();
170+
while (reader.TokenType == JsonTokenType.Number)
171+
{
172+
items.Add(reader.GetInt64());
173+
reader.Read();
174+
}
175+
176+
return items;
177+
}
178+
179+
/// <summary>int64 arrives quoted, so the elements are strings rather than numbers.</summary>
180+
public static List<long> GetInt64StringArrayPtr(ref TdJsonReader reader)
181+
{
182+
var items = new List<long>();
183+
184+
reader.Read();
185+
while (reader.TokenType == JsonTokenType.Number || reader.TokenType == JsonTokenType.String)
186+
{
187+
items.Add(reader.GetInt64String());
188+
reader.Read();
189+
}
190+
191+
return items;
192+
}
193+
194+
public static List<double> GetDoubleArrayPtr(ref TdJsonReader reader)
195+
{
196+
var items = new List<double>();
197+
198+
reader.Read();
199+
while (reader.TokenType == JsonTokenType.Number)
200+
{
201+
items.Add(reader.GetDouble());
202+
reader.Read();
203+
}
204+
205+
return items;
206+
}
207+
208+
public static List<string> GetStringArrayPtr(ref TdJsonReader reader)
209+
{
210+
var items = new List<string>();
211+
212+
reader.Read();
213+
while (reader.TokenType == JsonTokenType.String)
214+
{
215+
items.Add(reader.GetString());
216+
reader.Read();
217+
}
218+
219+
return items;
220+
}
221+
222+
public static List<byte[]> GetBase64StringArrayPtr(ref TdJsonReader reader)
223+
{
224+
var items = new List<byte[]>();
225+
226+
reader.Read();
227+
while (reader.TokenType == JsonTokenType.String)
228+
{
229+
items.Add(reader.GetBytesFromBase64());
230+
reader.Read();
231+
}
232+
233+
return items;
234+
}
235+
}
236+
}

‎Telegram.Benchmarks/Json/TdJsonReader.cs‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
using System.Text;
1313
using System.Text.Json;
1414

15-
namespace Telegram.Benchmarks.Json
15+
namespace Telegram.Td.Api
1616
{
1717
/// <summary>
1818
/// A pull reader over the pointer td_receive returns, shaped like the part of Utf8JsonReader
@@ -40,7 +40,7 @@ namespace Telegram.Benchmarks.Json
4040
///
4141
/// The one invariant to maintain is _index &lt;= _length, checked once per token in Read().
4242
/// </summary>
43-
internal unsafe ref struct TdJsonReader
43+
public unsafe ref struct TdJsonReader
4444
{
4545
private readonly byte* _buffer;
4646
private readonly int _length;
@@ -277,6 +277,25 @@ public bool ValueTextEquals(ReadOnlySpan<byte> text)
277277
return _valueLength == text.Length && ValueSpan.SequenceEqual(text);
278278
}
279279

280+
/// <summary>
281+
/// CRC32 of the current token, walking raw memory. Field dispatch compares names exactly -
282+
/// there are few enough per class for that to be cheap and it cannot collide - but a @type
283+
/// switch covers up to eighty constructors, where a hash is the right shape. Must agree
284+
/// with the values SchemaGenerator bakes into the switch.
285+
/// </summary>
286+
public uint ValueCrc32()
287+
{
288+
var table = ClientJson.Crc32Table;
289+
var crc = 0xFFFFFFFF;
290+
291+
for (int i = 0; i < _valueLength; i++)
292+
{
293+
crc = table[(crc ^ _buffer[_valueStart + i]) & 0xFF] ^ (crc >> 8);
294+
}
295+
296+
return crc ^ 0xFFFFFFFF;
297+
}
298+
280299
[MethodImpl(MethodImplOptions.AggressiveInlining)]
281300
public bool GetBoolean()
282301
{

‎Telegram.Benchmarks/README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,42 @@ than after. Tokenising is 60–70% of the current parse on .NET Native (5.39 µs
207207
`updateNewMessage`, 318 µs of ~440 µs for a page), so the full parse should land around **2–2.5×**
208208
once the generated parsers move over — better than the 1.5–1.8× estimated before measuring.
209209

210+
#### The generator emits both parsers
211+
212+
`SchemaGenerator` now emits a second set, `FromPtr_*`, reading through `TdJsonReader` instead of
213+
`Utf8JsonReader`. Both come from the same schema, so they cannot drift. It is opt-in — set
214+
`<TdPointerParser>true</TdPointerParser>` plus `<CompilerVisibleProperty Include="TdPointerParser" />`
215+
— and with it off the generated file is byte for byte what it has always been, which is the check
216+
that makes changing the generator safe without being able to build the app.
217+
218+
Field dispatch uses name length then an exact compare rather than CRC32: few enough fields per class
219+
for that to be cheap, and an unknown field cannot collide with a known one. `@type` dispatch keeps
220+
the CRC32 switch — up to eighty constructors is the wrong shape for a compare chain — using
221+
`TdJsonReader.ValueCrc32`, which walks raw memory and must agree with the values the generator bakes
222+
in.
223+
224+
Full parse on .NET Native, same payloads, same objects out:
225+
226+
| | `FromJson` | `FromPtr` | |
227+
| --- | ---: | ---: | ---: |
228+
| `updateUserStatus` | 682.2 ns | **335.6 ns** | 2.0× |
229+
| `updateFile` | 2.78 µs | **1.12 µs** | 2.5× |
230+
| `updateNewMessage` | 8.15 µs | **3.76 µs** | 2.2× |
231+
| `messages` ×50 | 436.3 µs | **181.7 µs** | 2.4× |
232+
| `updateOption` (escapes) | 1.41 µs | **493.2 ns** | 2.9× |
233+
234+
**2.0–2.9×**, matching the 2–2.5× predicted from the tokeniser numbers, with allocation identical to
235+
the byte. On the desktop JIT the two are at parity (97.2 µs against 97.8 µs for a page).
236+
237+
`Validation` runs the same assertions over both readers' output on every host, and on the desktop
238+
also compares the two object graphs field by field through reflection. Both report `validation ok`
239+
including on .NET Native, so the pointer parsers agree with the netstandard2.0 System.Text.Json ones
240+
across nested objects, vectors, abstract dispatch, escapes and unknown fields.
241+
242+
Not yet done: `updateFile` and `file` are routed through `ClientResultHandler` on the JSON path so
243+
the app can dedupe them, and the pointer path parses them inline instead. Matching that needs the
244+
interface to take a `TdJsonReader`, which belongs with wiring this into the app.
245+
210246
#### Hardening pass: free on .NET Native, 43% on the JIT
211247

212248
Bounds-checking every advance costs **43% on the desktop JIT** and **nothing on .NET Native**. Both

‎Telegram.Benchmarks/Suite.cs‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,18 +294,41 @@ private static unsafe void Tokenize(Harness harness)
294294
() => Json.PointerParsers.ParseLocalFile(localFilePtr, localFile.Length));
295295
}
296296

297-
private static void Parse(Harness harness)
297+
/// <summary>
298+
/// The whole parse, both generated readers, same payloads and same objects out. This is the
299+
/// number the tokeniser work is ultimately for.
300+
/// </summary>
301+
private static unsafe void Parse(Harness harness)
298302
{
299303
foreach (var payload in Corpus.Load())
300304
{
301305
var bytes = payload.Bytes;
302306
var length = payload.Length;
303307

308+
var native = Marshal.AllocHGlobal(length + 1);
309+
Marshal.Copy(bytes, 0, native, length);
310+
((byte*)native)[length] = 0; // the terminator TdJsonReader scans to
311+
var ptr = (byte*)native;
312+
304313
harness.Measure("FromJson", payload.Name,
305314
() => ClientJson.FromJson(new ReadOnlySpan<byte>(bytes, 0, length), BenchmarkResultHandler.Instance));
315+
_native[payload.Name] = (IntPtr)ptr;
316+
}
317+
318+
// A second pass rather than interleaved, so the report keeps each reader's rows
319+
// together.
320+
foreach (var payload in Corpus.Load())
321+
{
322+
var ptr = (byte*)_native[payload.Name];
323+
var length = payload.Length;
324+
325+
harness.Measure("FromPtr", payload.Name,
326+
() => ClientJson.FromPtr(ptr, length, BenchmarkResultHandler.Instance));
306327
}
307328
}
308329

330+
private static readonly Dictionary<string, IntPtr> _native = new();
331+
309332
private static void Dispatch(Harness harness)
310333
{
311334
var message = Fixtures.Load("message.json");

0 commit comments

Comments
 (0)