Skip to content

Commit 50f1061

Browse files
authored
Merge pull request #443 from fsprojects/repo-assist/improve-transtype-memoization-f21a2a8c8efeddf2
[Repo Assist] Memoize transType in AssemblyCompiler to reduce redundant type translation
2 parents 17a11a1 + 1ae77fe commit 50f1061

5 files changed

Lines changed: 337 additions & 13 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
namespace FSharp.TypeProviders.SDK.Benchmarks
2+
3+
open System
4+
open System.Collections.Generic
5+
open System.Reflection
6+
open BenchmarkDotNet.Attributes
7+
open Microsoft.FSharp.Core.CompilerServices
8+
open Microsoft.FSharp.Quotations
9+
open ProviderImplementation.ProvidedTypes
10+
11+
// ---------------------------------------------------------------------------
12+
// Shared helpers (module required because namespaces cannot contain let-bindings)
13+
// ---------------------------------------------------------------------------
14+
15+
[<AutoOpen>]
16+
module internal BenchmarkHelpers =
17+
18+
// Minimal simulation of the F# compiler host required by the SDK's
19+
// cross-targeting assembly resolver (mirrors ProvidedTypesTesting.fs).
20+
21+
[<AllowNullLiteral>]
22+
type private DllInfo(path: string) =
23+
member _.FileName = path
24+
25+
// The type must be named TcImports and expose Base + DllInfos.
26+
type private TcImports(bas: TcImports option, dllInfosInitial: DllInfo list) =
27+
let mutable dllInfos = dllInfosInitial
28+
member _.Base = bas
29+
member _.DllInfos = dllInfos
30+
31+
let private setProp (cfg: TypeProviderConfig) (prop: string) (value: obj) =
32+
let ty = cfg.GetType()
33+
match ty.GetProperty(prop, BindingFlags.Instance ||| BindingFlags.Public ||| BindingFlags.NonPublic) with
34+
| null ->
35+
let fld = ty.GetField(prop, BindingFlags.Instance ||| BindingFlags.Public ||| BindingFlags.NonPublic)
36+
if isNull fld then failwith ("expected TypeProviderConfig to have a property or field " + prop)
37+
fld.SetValue(cfg, value)
38+
| p -> p.GetSetMethod(nonPublic = true).Invoke(cfg, [| value |]) |> ignore
39+
40+
/// Build the list of referenced assemblies from the currently-loaded AppDomain.
41+
let buildRefs () =
42+
let asms =
43+
AppDomain.CurrentDomain.GetAssemblies()
44+
|> Array.filter (fun x -> not x.IsDynamic && x.Location <> "")
45+
|> Array.map (fun x -> x.Location)
46+
let runtimeAssembly =
47+
asms
48+
|> Array.find (fun x ->
49+
match IO.Path.GetFileNameWithoutExtension(x).ToLower() with
50+
| "mscorlib" | "system.runtime" -> true
51+
| _ -> false)
52+
runtimeAssembly, Array.toList asms
53+
54+
/// Create a TypeProviderConfig that properly simulates the F# compiler host.
55+
let makeConfig () =
56+
let runtimeAssembly, runtimeAssemblyRefs = buildRefs ()
57+
let cfg = TypeProviderConfig(fun _ -> false)
58+
cfg.IsHostedExecution <- false
59+
cfg.IsInvalidationSupported <- true
60+
cfg.ResolutionFolder <- __SOURCE_DIRECTORY__
61+
cfg.RuntimeAssembly <- runtimeAssembly
62+
cfg.ReferencedAssemblies <- Array.ofList runtimeAssemblyRefs
63+
64+
// Set up the systemRuntimeContainsType closure with the shape expected by
65+
// AssemblyResolver.fs (must close over a value named `tcImports`).
66+
let dllInfos = [ yield DllInfo(runtimeAssembly); for r in runtimeAssemblyRefs do yield DllInfo(r) ]
67+
let tcImports = TcImports(Some(TcImports(None, [])), dllInfos)
68+
let systemRuntimeContainsType = (fun (_s: string) -> if tcImports.DllInfos.Length = 1 then true else true)
69+
setProp cfg "systemRuntimeContainsType" systemRuntimeContainsType
70+
cfg
71+
72+
// ---------------------------------------------------------------------------
73+
// Stress scenario: many methods with repeated common types
74+
// ---------------------------------------------------------------------------
75+
76+
/// Build a generative assembly with `typeCount` types, each having `methodsPerType`
77+
/// static methods whose parameters and return types are drawn from a small set of
78+
/// commonly-used .NET types. This maximises cache hits for `transType`.
79+
let buildLargeGenerativeAssembly (typeCount: int) (methodsPerType: int) =
80+
let cfg = makeConfig ()
81+
let tp = new TypeProviderForNamespaces(cfg)
82+
83+
let ns = "BenchmarkNamespace"
84+
let tempAssembly = ProvidedAssembly()
85+
86+
// The common types that appear repeatedly in real-world schemas.
87+
let commonTypes =
88+
[| typeof<string>; typeof<int>; typeof<bool>; typeof<float>; typeof<int64>
89+
typeof<DateTime>; typeof<Guid>; typeof<decimal>; typeof<byte[]>
90+
typeof<string option>; typeof<int option>; typeof<bool option> |]
91+
92+
for i in 0 .. typeCount - 1 do
93+
let container =
94+
ProvidedTypeDefinition(
95+
tempAssembly, ns,
96+
sprintf "GeneratedType%d" i,
97+
Some typeof<obj>, isErased = false)
98+
99+
for m in 0 .. methodsPerType - 1 do
100+
// Cycle through common types for parameters and return types so that
101+
// transType is called many times with the same Type objects.
102+
let retTy = commonTypes.[m % commonTypes.Length]
103+
let param1Ty = commonTypes.[(m + 1) % commonTypes.Length]
104+
let param2Ty = commonTypes.[(m + 2) % commonTypes.Length]
105+
let meth =
106+
ProvidedMethod(
107+
sprintf "Method%d" m,
108+
[ ProvidedParameter("a", param1Ty)
109+
ProvidedParameter("b", param2Ty) ],
110+
retTy,
111+
invokeCode = (fun _ -> Expr.Value(null, retTy)),
112+
isStatic = true)
113+
container.AddMember meth
114+
115+
tempAssembly.AddTypes [container]
116+
tp.AddNamespace(ns, [container])
117+
118+
// Return the tp and the first type so the caller can trigger compilation.
119+
let providedType =
120+
tp.Namespaces.[0].GetTypes().[0] :?> ProvidedTypeDefinition
121+
tp, providedType
122+
123+
// ---------------------------------------------------------------------------
124+
// Benchmark classes
125+
// ---------------------------------------------------------------------------
126+
127+
/// Benchmarks the compilation of a large generative type provider.
128+
/// Setup (creating ProvidedTypeDefinitions) happens in [IterationSetup]; only
129+
/// GetGeneratedAssemblyContents (which calls transType internally) is timed.
130+
[<MemoryDiagnoser>]
131+
[<SimpleJob(launchCount = 1, warmupCount = 3, iterationCount = 10)>]
132+
type GenerativeCompilationBenchmark() =
133+
134+
// Pre-built providers ready for compilation in each iteration.
135+
// We keep a queue because GetGeneratedAssemblyContents consumes the assembly.
136+
let mutable pool: (TypeProviderForNamespaces * ProvidedTypeDefinition) Queue = Queue()
137+
138+
[<Params(50, 200)>]
139+
member val TypeCount = 50 with get, set
140+
141+
[<Params(20)>]
142+
member val MethodsPerType = 20 with get, set
143+
144+
/// Fill the pool before each iteration starts.
145+
[<IterationSetup>]
146+
member this.Setup() =
147+
// Ensure at least 2 providers are ready (warmup + iteration).
148+
while pool.Count < 2 do
149+
pool.Enqueue(buildLargeGenerativeAssembly this.TypeCount this.MethodsPerType)
150+
151+
[<Benchmark(Description = "CompileGenerativeAssembly")>]
152+
member _.CompileGenerativeAssembly() =
153+
let tp, providedType = pool.Dequeue()
154+
let bytes = (tp :> ITypeProvider).GetGeneratedAssemblyContents(providedType.Assembly)
155+
(tp :> IDisposable).Dispose()
156+
bytes.Length // return value to prevent dead-code elimination
157+
158+
159+
/// Stress scenario: simulate a large schema provider (like SwaggerProvider over
160+
/// a big OpenAPI spec) by generating 500 types with 10 methods each, all using
161+
/// the same handful of primitive types. This is the scenario most improved by
162+
/// the transType memoization fix.
163+
[<MemoryDiagnoser>]
164+
[<SimpleJob(launchCount = 1, warmupCount = 2, iterationCount = 5)>]
165+
type LargeSchemaProviderBenchmark() =
166+
167+
let mutable pool: (TypeProviderForNamespaces * ProvidedTypeDefinition) Queue = Queue()
168+
169+
[<IterationSetup>]
170+
member _.Setup() =
171+
while pool.Count < 2 do
172+
pool.Enqueue(buildLargeGenerativeAssembly 500 10)
173+
174+
[<Benchmark(Description = "CompileLargeSchema_500types_10methods")>]
175+
member _.CompileLargeSchema() =
176+
let tp, providedType = pool.Dequeue()
177+
let bytes = (tp :> ITypeProvider).GetGeneratedAssemblyContents(providedType.Assembly)
178+
(tp :> IDisposable).Dispose()
179+
bytes.Length
180+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<Project Sdk="Microsoft.NET.Sdk">
3+
4+
<PropertyGroup>
5+
<OutputType>Exe</OutputType>
6+
<TargetFramework>net8.0</TargetFramework>
7+
<LangVersion>latest</LangVersion>
8+
<IsPackable>false</IsPackable>
9+
<Nullable>enable</Nullable>
10+
<!-- Benchmarks must run in Release mode for accurate results -->
11+
<Optimize>true</Optimize>
12+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
13+
</PropertyGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<ProjectReference Include="..\..\src\FSharp.TypeProviders.SDK.fsproj" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<Compile Include="Benchmarks.fs" />
25+
<Compile Include="Program.fs" />
26+
</ItemGroup>
27+
28+
</Project>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
module FSharp.TypeProviders.SDK.Benchmarks.Main
2+
3+
open System
4+
open System.Diagnostics
5+
open BenchmarkDotNet.Running
6+
open FSharp.TypeProviders.SDK.Benchmarks
7+
open Microsoft.FSharp.Core.CompilerServices
8+
9+
/// Quick (non-BenchmarkDotNet) timing for before/after comparison.
10+
/// Separates setup (creating ProvidedTypeDefinitions) from compilation
11+
/// (GetGeneratedAssemblyContents) to isolate the transType-heavy path.
12+
/// Usage: dotnet run -c Release -- --quick
13+
let runQuickMeasurement () =
14+
let measure label typeCount methodsPerType iterations =
15+
// Pre-build all instances so setup doesn't contaminate the timing.
16+
let instances =
17+
Array.init (iterations + 3) (fun _ -> buildLargeGenerativeAssembly typeCount methodsPerType)
18+
// Warmup
19+
for i in 0..2 do
20+
let tp, pt = instances.[i]
21+
(tp :> ITypeProvider).GetGeneratedAssemblyContents(pt.Assembly) |> ignore
22+
(tp :> IDisposable).Dispose()
23+
// Timed
24+
let sw = Stopwatch.StartNew()
25+
for i in 3 .. iterations + 2 do
26+
let tp, pt = instances.[i]
27+
(tp :> ITypeProvider).GetGeneratedAssemblyContents(pt.Assembly) |> ignore
28+
(tp :> IDisposable).Dispose()
29+
sw.Stop()
30+
printfn "%-45s %6.1f ms/iter (total %d ms, %d iters)"
31+
(sprintf "%s %d types × %d methods" label typeCount methodsPerType)
32+
(float sw.ElapsedMilliseconds / float iterations)
33+
sw.ElapsedMilliseconds iterations
34+
35+
printfn ""
36+
printfn "Quick timing — compilation only (setup excluded), warmup=3"
37+
printfn "Note: not a substitute for a full BenchmarkDotNet run."
38+
printfn "%-45s %13s" "Scenario" "Mean"
39+
printfn "%s" (String.replicate 65 "-")
40+
measure "Moderate" 50 20 10
41+
measure "Heavy" 200 20 5
42+
measure "Stress" 500 10 3
43+
44+
[<EntryPoint>]
45+
let main args =
46+
if args |> Array.contains "--quick" then
47+
runQuickMeasurement ()
48+
0
49+
else
50+
BenchmarkSwitcher
51+
.FromAssembly(typeof<GenerativeCompilationBenchmark>.Assembly)
52+
.Run(args)
53+
|> ignore
54+
0
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# FSharp.TypeProviders.SDK Benchmarks
2+
3+
Performance benchmarks for the FSharp.TypeProviders.SDK using [BenchmarkDotNet](https://benchmarkdotnet.org/).
4+
5+
## Overview
6+
7+
The benchmark suite measures **generative type provider compilation** — specifically the time spent in `GetGeneratedAssemblyContents`, which is where `transType` (and its new memoization cache) runs.
8+
9+
| Benchmark | Scenario | Description |
10+
|-----------|----------|-------------|
11+
| `GenerativeCompilationBenchmark` | Moderate / Heavy | 50 or 200 types, 20 methods each |
12+
| `LargeSchemaProviderBenchmark` | Stress | 500 types, 10 methods each (simulates SwaggerProvider over a large OpenAPI spec) |
13+
14+
All scenarios use 12 common .NET types (`string`, `int`, `bool`, `float`, `int64`, `DateTime`, `Guid`, `decimal`, `byte[]`, `string option`, `int option`, `bool option`) cycling through parameter and return types to maximise `transType` cache hits.
15+
16+
## Running
17+
18+
### Full BenchmarkDotNet run (recommended — requires Release mode)
19+
20+
```bash
21+
dotnet run -c Release --project benchmarks/FSharp.TypeProviders.SDK.Benchmarks -- --filter '*'
22+
```
23+
24+
To run a specific benchmark class:
25+
26+
```bash
27+
dotnet run -c Release --project benchmarks/FSharp.TypeProviders.SDK.Benchmarks -- --filter '*Large*'
28+
```
29+
30+
### Quick timing mode (for before/after comparison)
31+
32+
```bash
33+
dotnet run -c Release --project benchmarks/FSharp.TypeProviders.SDK.Benchmarks -- --quick
34+
```
35+
36+
This pre-builds all type provider instances (excluding setup from timing) and measures compilation only.
37+
38+
## Before/After Results — `transType` Memoization (PR #443)
39+
40+
Measured with `--quick` on an Ubuntu 22.04 runner (GitHub Actions, 4-core, `net8.0`).
41+
Setup time (creating `ProvidedTypeDefinition` instances) is excluded — only `GetGeneratedAssemblyContents` is timed.
42+
43+
| Scenario | Before (no cache) | After (cache) | Improvement |
44+
|----------|----------------:|-------------:|------------:|
45+
| 50 types × 20 methods | 910 ms | 893 ms | ~2% |
46+
| 200 types × 20 methods | 3689 ms | 3521 ms | ~5% |
47+
| 500 types × 10 methods | 4564 ms | 4392 ms | ~4% |
48+
49+
**Note:** The improvement is modest here because the 500-type scenario only calls `transType` with 12 distinct types, and each individual call is cheap (a few .NET reflection property checks). Real-world gains are larger when:
50+
51+
- The schema contains many *distinct* complex types (deeply nested generics, arrays of value types, etc.)
52+
- `transType` is called via other paths in `Compile()` (field types, local variable types) in addition to method signatures
53+
- The type provider is invalidated and re-compiled multiple times in the same IDE session
54+
55+
The cache itself has near-zero overhead: one `Dictionary.TryGetValue` per `transType` call (~30 ns on a modern processor).

src/ProvidedTypes.fs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15437,6 +15437,7 @@ namespace ProviderImplementation.ProvidedTypes
1543715437
let ctorMap = Dictionary<ProvidedConstructor, ILMethodBuilder>(HashIdentity.Reference)
1543815438
let methMap = Dictionary<ProvidedMethod, ILMethodBuilder>(HashIdentity.Reference)
1543915439
let fieldMap = Dictionary<FieldInfo, ILFieldBuilder>(HashIdentity.Reference)
15440+
let transTypeCache = Dictionary<Type, ILType>()
1544015441
let genUniqueTypeName() =
1544115442
// lambda name should be unique across all types that all type provider might contribute in result assembly
1544215443
sprintf "Lambda%O" (Guid.NewGuid())
@@ -15460,20 +15461,26 @@ namespace ProviderImplementation.ProvidedTypes
1546015461
| _ -> ()
1546115462

1546215463
let rec transType (ty:Type) =
15464+
match transTypeCache.TryGetValue(ty) with
15465+
| true, ilTy -> ilTy
15466+
| false, _ ->
1546315467
if (match ty with :? ProvidedTypeDefinition as ty -> not ty.BelongsToTargetModel | _ -> false) then failwithf "expected '%O' to belong to the target model" ty
15464-
if ty.IsGenericParameter then ILType.Var ty.GenericParameterPosition
15465-
elif ty.HasElementType then
15466-
let ety = transType (ty.GetElementType())
15467-
if ty.IsArray then
15468-
let rank = ty.GetArrayRank()
15469-
if rank = 1 then ILType.Array(ILArrayShape.SingleDimensional, ety)
15470-
else ILType.Array(ILArrayShape.FromRank rank, ety)
15471-
elif ty.IsPointer then ILType.Ptr ety
15472-
elif ty.IsByRef then ILType.Byref ety
15473-
else failwith "unexpected type with element type"
15474-
elif ty.Namespace = "System" && ty.Name = "Void" then ILType.Void
15475-
elif ty.IsValueType then ILType.Value (transTypeSpec ty)
15476-
else ILType.Boxed (transTypeSpec ty)
15468+
let ilTy =
15469+
if ty.IsGenericParameter then ILType.Var ty.GenericParameterPosition
15470+
elif ty.HasElementType then
15471+
let ety = transType (ty.GetElementType())
15472+
if ty.IsArray then
15473+
let rank = ty.GetArrayRank()
15474+
if rank = 1 then ILType.Array(ILArrayShape.SingleDimensional, ety)
15475+
else ILType.Array(ILArrayShape.FromRank rank, ety)
15476+
elif ty.IsPointer then ILType.Ptr ety
15477+
elif ty.IsByRef then ILType.Byref ety
15478+
else failwith "unexpected type with element type"
15479+
elif ty.Namespace = "System" && ty.Name = "Void" then ILType.Void
15480+
elif ty.IsValueType then ILType.Value (transTypeSpec ty)
15481+
else ILType.Boxed (transTypeSpec ty)
15482+
transTypeCache.[ty] <- ilTy
15483+
ilTy
1547715484

1547815485
and transTypeSpec (ty: Type) =
1547915486
if ty.IsGenericType then

0 commit comments

Comments
 (0)