Skip to content

Commit 7c99daf

Browse files
committed
Refocus perf follow-up on targeted decode diagnostics
1 parent e47c433 commit 7c99daf

5 files changed

Lines changed: 175 additions & 30 deletions

File tree

TASKS.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,23 @@
22

33
Active work only. Historical completed work lives in [notes/AGENT_NOTES.md](notes/AGENT_NOTES.md) and [AGENTS.md](AGENTS.md).
44

5-
- [x] **Task 42: Finalize the core boxed contract runtime**
6-
- Remove `ISchema`, `SchemaDefinition`, `SchemaField`, and `obj[] -> obj` record construction from the active core implementation.
7-
- Move the public authored DSL to `Schema.*` over `Schema<'T>`.
8-
- Delete the old schema DSL instead of keeping compatibility shims.
9-
- Retarget `Json`, `Xml`, `Yaml`, `KeyValue`, and `JsonSchema` to compile from the new boxed contract IR directly.
10-
- Remove any `Lowering.lower` or legacy boxed-schema bridge from the runtime path.
11-
- Update tests, benchmarks, and docs to the new surface.
12-
- Explicitly exclude bridge internals from this task. Bridge cleanup can follow once the core runtime shape is stable.
13-
- Completion bar: there is no old boxed DSL left in the core runtime path or public authored surface.
5+
- Recently completed: **Task 42** landed the boxed contract runtime and `Schema<'T>` surface. Keep the historical rationale, benchmark notes, and follow-up findings in [notes/AGENT_NOTES.md](notes/AGENT_NOTES.md) and [docs/PROFILE-REPORT-ANALYSIS-INSTRUCTIONAL.md](docs/PROFILE-REPORT-ANALYSIS-INSTRUCTIONAL.md).
6+
7+
- [ ] **Task 50: Run a narrow post-Task-42 performance follow-up**
8+
- Treat the current numbers as two separate problems: typed record decode overhead and handwritten parser hot loops.
9+
- Stay disciplined: no broad runtime rewrite until a narrower experiment wins clearly on the published scenario set.
10+
- First pass:
11+
- generalize the typed JSON record-decode lane beyond the benchmark-only hand-written shapes
12+
- profile string-heavy decode and serialize hotspots again after each step
13+
- identify whether the next worthwhile work is parser scanning, string handling, unknown-field skipping, or record assembly
14+
- Output:
15+
- refresh the benchmark notes with before/after numbers
16+
- either promote one proven optimization direction into production work or explicitly close the line of investigation
1417

1518
- [ ] **Task 49: Review and improve the new DSL for DX**
16-
- After Task 42, review the new `Schema.*` surface for compactness, clarity, and maintainability.
19+
- Review the new `Schema.*` surface for compactness, clarity, and maintainability after the immediate perf follow-up is settled.
1720
- Capture improvements in `PLAN-TO-IMPROVE-DSL`.
18-
- Do not implement that review directly in the same pass unless it is required to complete Task 42.
21+
- Do not fold speculative API cleanup into performance work unless it materially simplifies a measured hot path.
1922

2023
- [ ] **Task 37: Add structured decode error outputs for app boundaries**
2124
- Provide a structured error model that callers can use for REST responses, startup config failures, and message rejection logs.

benchmarks/BenchmarkScenarios.fs

Lines changed: 144 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ type TelemetryPoint = {
5454
Healthy: bool
5555
}
5656

57+
type FlatProbe = {
58+
Id: int
59+
Name: string
60+
Code: string
61+
Enabled: bool
62+
Score: float
63+
Trace: string
64+
}
65+
5766
module ParserScanExperiment =
5867
let private mixHash state value = (state * 16777619) ^^^ value
5968

@@ -279,11 +288,7 @@ module TypedJsonExperiment =
279288

280289
current
281290

282-
let private record2
283-
(field1: Field<'A>)
284-
(field2: Field<'B>)
285-
(ctor: 'A -> 'B -> 'T)
286-
: Decoder<'T> =
291+
let private record2 (field1: Field<'A>) (field2: Field<'B>) (ctor: 'A -> 'B -> 'T) : Decoder<'T> =
287292
fun src ->
288293
let mutable value1 = Unchecked.defaultof<'A>
289294
let mutable value2 = Unchecked.defaultof<'B>
@@ -652,7 +657,9 @@ module TypedJsonExperiment =
652657
let private articlesDecoder = list articleDecoder
653658
let private telemetryDecoder = list telemetryPointDecoder
654659

655-
let deserializeSmallMessageBytes (bytes: byte[]) = deserializeBytes smallMessageDecoder bytes
660+
let deserializeSmallMessageBytes (bytes: byte[]) =
661+
deserializeBytes smallMessageDecoder bytes
662+
656663
let deserializePeopleBytes (bytes: byte[]) = deserializeBytes peopleDecoder bytes
657664
let deserializeArticlesBytes (bytes: byte[]) = deserializeBytes articlesDecoder bytes
658665
let deserializeTelemetryBytes (bytes: byte[]) = deserializeBytes telemetryDecoder bytes
@@ -722,9 +729,28 @@ module Schemas =
722729
|> Schema.field "Healthy" (fun (point: TelemetryPoint) -> point.Healthy)
723730
|> Schema.build
724731

732+
let flatProbe =
733+
Schema.record (fun id name code enabled score trace -> {
734+
Id = id
735+
Name = name
736+
Code = code
737+
Enabled = enabled
738+
Score = score
739+
Trace = trace
740+
})
741+
|> Schema.field "Id" (fun (probe: FlatProbe) -> probe.Id)
742+
|> Schema.field "Name" (fun (probe: FlatProbe) -> probe.Name)
743+
|> Schema.field "Code" (fun (probe: FlatProbe) -> probe.Code)
744+
|> Schema.field "Enabled" (fun (probe: FlatProbe) -> probe.Enabled)
745+
|> Schema.field "Score" (fun (probe: FlatProbe) -> probe.Score)
746+
|> Schema.field "Trace" (fun (probe: FlatProbe) -> probe.Trace)
747+
|> Schema.build
748+
725749
let personList = Schema.list person
726750
let articleList = Schema.list article
727751
let telemetryList = Schema.list telemetryPoint
752+
let flatProbeList = Schema.list flatProbe
753+
let stringList = Schema.list Schema.string
728754

729755
module Data =
730756
let private stjOptions = JsonSerializerOptions()
@@ -820,29 +846,48 @@ module Data =
820846
let serializeJsonNewtonsoft value = JsonConvert.SerializeObject(value)
821847
let utf8Bytes (json: string) = Encoding.UTF8.GetBytes(json)
822848

823-
let createParserStringArray count =
849+
let createStringSamples count =
824850
[ 1..count ]
825851
|> List.map (fun index ->
826852
String.replicate 2 $"entry-{index}-with-escapes-\"quoted\"-and-\\\\slashes\\\\-plus-newlines\n")
827-
|> serializeJson
853+
854+
let createFlatProbes count =
855+
[ 1..count ]
856+
|> List.map (fun index -> {
857+
Id = index
858+
Name = $"record-{index}"
859+
Code = $"X{index}"
860+
Enabled = index % 2 = 0
861+
Score = 18.25 + float index / 10.0
862+
Trace = $"01HV{index:D4}ABCDEF"
863+
})
864+
865+
let createParserStringArray count =
866+
createStringSamples count |> serializeJson
828867

829868
let createParserNumberArray count =
830869
[ 1..count ]
831870
|> List.map (fun index -> 1_700_000_000_000L + int64 (index * 37))
832871
|> serializeJson
833872

834-
let createParserFlatObjectArray count =
873+
let createParserFlatObjectArray count = createFlatProbes count |> serializeJson
874+
875+
///
876+
/// The unknown-field variant keeps the core record shape unchanged so the
877+
/// profile can isolate skip-path cost without nested-record noise.
878+
let createParserFlatObjectArrayWithUnknownFields count =
835879
let items =
836-
[ 1..count ]
837-
|> List.map (fun index ->
880+
createFlatProbes count
881+
|> List.map (fun probe ->
838882
sprintf
839-
"""{"Id":%d,"Name":"record-%d","Code":"X%d","Enabled":%s,"Score":%s,"Trace":"01HV%04dABCDEF"}"""
840-
index
841-
index
842-
index
843-
(if index % 2 = 0 then "true" else "false")
844-
((18.25 + float index / 10.0).ToString(System.Globalization.CultureInfo.InvariantCulture))
845-
index)
883+
"""{"Id":%d,"Name":"%s","Code":"%s","Enabled":%s,"Score":%s,"Trace":"%s","Extra":"ignored-%d"}"""
884+
probe.Id
885+
probe.Name
886+
probe.Code
887+
(if probe.Enabled then "true" else "false")
888+
(probe.Score.ToString(System.Globalization.CultureInfo.InvariantCulture))
889+
probe.Trace
890+
probe.Id)
846891

847892
"[" + String.concat "," items + "]"
848893

@@ -904,6 +949,22 @@ module Workloads =
904949
^^^ System.Decimal.ToInt32(System.Decimal.Truncate(value.Voltage * 100M)))
905950
0
906951

952+
let private hashStrings (values: string list) =
953+
values |> List.fold (fun acc value -> acc ^^^ value.Length) 0
954+
955+
let private hashFlatProbes (values: FlatProbe list) =
956+
values
957+
|> List.fold
958+
(fun acc value ->
959+
acc
960+
^^^ value.Id
961+
^^^ value.Name.Length
962+
^^^ value.Code.Length
963+
^^^ (if value.Enabled then 1 else 0)
964+
^^^ int (value.Score * 100.0)
965+
^^^ value.Trace.Length)
966+
0
967+
907968
let private hashJsonValue (value: JsonValue) =
908969
let rec loop state current =
909970
match current with
@@ -983,6 +1044,43 @@ module Workloads =
9831044
HashValue = (fun boxed -> hashJsonValue (unbox boxed))
9841045
}
9851046

1047+
///
1048+
/// Decode-only diagnostics keep serialize out of the picture when the
1049+
/// question is whether string handling, flat-record assembly, or
1050+
/// unknown-field skipping is the next worthwhile decode target.
1051+
let private makeDecodeDiagnosticWorkload<'T>
1052+
(name: string)
1053+
(description: string)
1054+
(deserializeIterations: int)
1055+
(decodeJson: string)
1056+
(codec: Json.Codec<'T>)
1057+
(stjDeserialize: string -> 'T)
1058+
(hashValue: 'T -> int)
1059+
=
1060+
let decodeBytes = Data.utf8Bytes decodeJson
1061+
1062+
let diagnosticOnly () =
1063+
failwith "This diagnostic workload is intended for decode and parser operations only."
1064+
1065+
{
1066+
Name = name
1067+
Description = description
1068+
SerializeIterations = 1
1069+
DeserializeIterations = deserializeIterations
1070+
JsonSizeBytes = decodeBytes.Length
1071+
CodecMapperSerialize = (fun () -> diagnosticOnly ())
1072+
StjSerialize = (fun () -> diagnosticOnly ())
1073+
NewtonsoftSerialize = (fun () -> diagnosticOnly ())
1074+
OurParserScanBytes = (fun () -> ParserScanExperiment.scanWithOurParser decodeBytes)
1075+
Utf8JsonReaderScanBytes = (fun () -> ParserScanExperiment.scanWithUtf8JsonReader decodeBytes)
1076+
CodecMapperDeserializeBytes = (fun () -> box (Json.deserializeBytes codec decodeBytes))
1077+
TypedExperimentDeserializeBytes = None
1078+
StjDeserialize = (fun () -> box (stjDeserialize decodeJson))
1079+
NewtonsoftDeserialize = (fun () -> box (JsonConvert.DeserializeObject<'T>(decodeJson)))
1080+
HashSerialized = String.length
1081+
HashValue = (fun boxed -> hashValue (unbox boxed))
1082+
}
1083+
9861084
let createLegacyPersonBatch recordCount =
9871085
let value = Data.createPeople recordCount
9881086
let decodeJson = Data.serializeJson value
@@ -1120,6 +1218,7 @@ module Workloads =
11201218
let stringArray = Data.createParserStringArray 1000
11211219
let numberArray = Data.createParserNumberArray 4000
11221220
let flatObjects = Data.createParserFlatObjectArray 400
1221+
let flatObjectsWithUnknowns = Data.createParserFlatObjectArrayWithUnknownFields 400
11231222

11241223
[|
11251224
makeParserDiagnosticWorkload
@@ -1139,6 +1238,33 @@ module Workloads =
11391238
"Parser-only diagnostic: flat object traversal with repeated property names."
11401239
1500
11411240
flatObjects
1241+
1242+
makeDecodeDiagnosticWorkload
1243+
"decode-strings-1000"
1244+
"Decode diagnostic: escaped string array to isolate string unescaping and list construction."
1245+
3000
1246+
stringArray
1247+
(Json.compile Schemas.stringList)
1248+
(fun json -> System.Text.Json.JsonSerializer.Deserialize<string list>(json, stjOptions))
1249+
hashStrings
1250+
1251+
makeDecodeDiagnosticWorkload
1252+
"decode-flat-objects-400"
1253+
"Decode diagnostic: flat records to isolate field dispatch and record assembly."
1254+
1500
1255+
flatObjects
1256+
(Json.compile Schemas.flatProbeList)
1257+
(fun json -> System.Text.Json.JsonSerializer.Deserialize<FlatProbe list>(json, stjOptions))
1258+
hashFlatProbes
1259+
1260+
makeDecodeDiagnosticWorkload
1261+
"decode-flat-objects-400-unknown-fields"
1262+
"Decode diagnostic: flat records with ignored fields to isolate unknown-field skipping."
1263+
1500
1264+
flatObjectsWithUnknowns
1265+
(Json.compile Schemas.flatProbeList)
1266+
(fun json -> System.Text.Json.JsonSerializer.Deserialize<FlatProbe list>(json, stjOptions))
1267+
hashFlatProbes
11421268
|]
11431269

11441270
let names =

docs/HOW_TO_PROFILE_BENCHMARK_HOT_PATHS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ Artifacts land under `.artifacts/profiling/<operation>-<scenario-or-records>-<it
2828
- `stj-deserialize`
2929
- `newtonsoft-serialize`
3030
- `newtonsoft-deserialize`
31+
- `our-parser-scan-bytes`
32+
- `utf8jsonreader-scan-bytes`
33+
- `typed-experiment-deserialize-bytes`
3134

3235
## Typical workflow
3336

@@ -51,6 +54,7 @@ Read `perf.stat.txt` for high-level counters and `perf.report.txt` for the hotte
5154

5255
- The profile wrapper now defaults to the `person-batch-25` scenario from the shared benchmark matrix.
5356
- Pass a scenario name such as `telemetry-500` or `escaped-articles-20` to profile one of the standard workloads.
57+
- The diagnostics matrix now also includes decode-focused scenarios such as `decode-strings-1000`, `decode-flat-objects-400`, and `decode-flat-objects-400-unknown-fields` when you need to isolate string handling, flat record assembly, or unknown-field skipping.
5458
- Passing a plain integer as the third argument still uses the legacy nested-record batch with `--records <n>`.
5559
- The wrapper sets `DOTNET_PerfMapEnabled=3` and `COMPlus_PerfMapEnabled=3` so `perf inject --jit` has the metadata it needs for managed symbol names.
5660
- If `perf record` is blocked by local kernel permissions, the script will fail before writing `perf.report.txt`. In that case, fix local `perf` permissions first and rerun the same command.

notes/AGENT_NOTES.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,10 @@ This keeps compilation cost visible and avoids hidden recompilation or implicit
150150
- `benchmarks/CodecMapper.Benchmarks/Program.fs` now forces BenchmarkDotNet onto `InProcessEmitToolchain` to avoid child-project generation entirely.
151151
- The remaining warning during local runs is just Linux process-priority elevation failure (`Permission denied`), which does not stop benchmarks from executing.
152152
- A manual Release runner was added in `benchmarks/CodecMapper.Benchmarks.Runner` to keep benchmark reporting moving while that tooling issue remains unresolved.
153-
- `BenchmarkScenarios.fs` now expresses the benchmark-only typed JSON decode lane through reusable `recordN` and `list` combinators instead of one bespoke decoder loop per workload shape. Keep future `Task 42` work in that benchmark-only lane unless the production runtime decision has been made explicitly.
153+
- `Json.fs` now ships a production typed-record decode fast path for supported JSON record shapes, backed by `JsonTypedRecordDecode.fs`.
154+
- Focused reruns on April 2, 2026 showed the old benchmark-only typed lane no longer beating production on the checked scenarios (`small-message` and `telemetry-500`), so treat it as a comparison artifact rather than as the main forward path.
155+
- `BenchmarkScenarios.fs` now includes decode diagnostics for escaped string arrays, flat records, and flat records with unknown fields so profiling can separate parser scanning, string handling, record assembly, and skip-path cost more cleanly.
156+
- A first `.NET`-only escaped-string decoder experiment that replaced `StringBuilder` with a pooled `char[]` path produced mixed results and was reverted the same day: it improved the synthetic long escaped-string array diagnostic, but it did not hold up cleanly on the more realistic `escaped-articles-20` workload. Treat string decode as still open, but prefer narrower experiments over broad decoder rewrites.
154157
- Keep the manual Release runner for fast local snapshots and README updates; use BenchmarkDotNet when you specifically want richer statistical output.
155158

156159
## Formatting

tests/CodecMapper.Tests/JsonParserTests.fs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ let ``Decode unicode escape string JSON`` () =
3131
let decoded = Json.deserialize codec (quoted """Hello, Wor\u006c\u0064!""")
3232
test <@ decoded = "Hello, World!" @>
3333

34+
[<Fact>]
35+
let ``Decode escaped strings with mixed unicode content JSON`` () =
36+
let codec = Json.compileSchema Schema.string
37+
38+
let decoded =
39+
Json.deserialize codec (quoted """Ol\u00e1,\n\t\"mundo\" \\ snowman: \u2603""")
40+
41+
test <@ decoded = "Olá,\n\t\"mundo\" \\ snowman: ☃" @>
42+
3443
[<Fact>]
3544
let ``Round-trip bool JSON`` () =
3645
let codec = Json.compileSchema Schema.bool

0 commit comments

Comments
 (0)