Commit 2780de3
Proxy method slots and array-based interceptor dispatch
Implements docs/plans/proxy-method-slots.md, then goes further: generated
proxies assign a compile-time integer slot to every intercepted method,
publish one static ProxyMethodTable, and cache the resolved handler per
proxy instance, per slot - a warm call is a field load + delegate invoke,
with no dictionary probe, no virtual SelectHandler dispatch, and no
redundant range checks anywhere on the path.
ActualLab.Interception:
- ProxyMethodTable: one immutable static table per generated proxy type -
MethodInfo[] indexed by slot + a cold-path reverse MethodInfo -> slot map
with alias fallback (base-class and equivalent interface declarations
resolve to the effective slot).
- ProxyMethodRef: table-qualified (Table, Index) method reference.
- Invocation: always carries (MethodTable, MethodIndex); the constructor
performs the single range check, Method resolves via unchecked access.
The legacy MethodInfo-based constructor is removed.
- InterceptorBinding: one per (interceptor, method table) pair - the shared
per-slot handler cache and slow-path resolver (GetAndCacheHandler), which
also fills the proxy's per-slot field. A slot the interceptor leaves
unhandled resolves to the NoHandler marker, which generated proxies
detect by reference to invoke their typed original-call delegate
directly (no boxed results).
- Interceptor: SelectHandler is called at most once per (interceptor,
method table, slot) and its result is cached; per-call logic belongs
inside the returned handler. The MethodInfo-keyed handler cache,
GetHandler, InterceptorExt, and all dynamic-binding machinery are
deleted. Only two dictionaries remain: the bind-time table -> binding
map and the cold MethodDef cache.
- IProxy is now { ProxyMethodTable MethodTable; InterceptorBinding
Binding } - no direct interceptor access; both members are explicit
interface implementations. The binding is assigned just once, right
after the proxy construction; the setter throws on rebinding and
validates the binding's method table. With rebinding gone, handler
fields only ever go from null to the single published handler, so all
field access is plain and read-optimized.
ActualLab.Generators: proxies emit a static __methodTable, one __binding
field, one __handlerN field per slot, and two-step dispatch (lookup on
miss, then invoke); per-method MethodInfo statics, per-return-type
intercept delegate fields, and __interceptor are gone.
Fusion: ComputeServiceInterceptor / RemoteComputeServiceInterceptor
compose their command/RPC fallback inside the once-per-slot SelectHandler.
Breaking changes: proxies generated by older ActualLab.Generators versions
must be recompiled; SelectHandler semantics changed from per-call to
once-per-slot; hand-written proxies publish a ProxyMethodTable and use the
two-step dispatch (see AltClassProxy in tests); interceptors can no longer
be reassigned.
Benchmarks (Release, .NET 10, same machine; before -> after):
- Cached compute calls: Long 36.5 -> 23.1 ns (-36%), String 46.5 -> 29.1 ns
(-37%), SessionAndString 44.7 -> 33.8 ns (-26%), recompute -9..-16%.
- Raw proxy dispatch (vs Castle.DynamicProxy comparison test, Fusion
variants): interceptor path 3.1-4.3x faster (Void() 155M -> 473M ops/s,
Int() 102M -> 441M, Task(ct) 66M -> 215M), pass-through 1.9-5.2x faster;
per-call allocations unchanged (0-32 B). Fusion proxies are now
3.9-6.9x faster than Castle.DynamicProxy.
Docs: PartAP-PG / PartAP-D examples, diagrams, and plan statuses updated;
the old fusion-intro-v1 slide deck is intentionally untouched.
## Development log
**Alex:** Read docs/plans/proxy-method-slots.md - you'll be working on that problem.
**Fable5:** Read the plan and the relevant interception/generator sources; set up a task list covering the method table, interceptor binding, generator changes, built-in interceptor migration, tests, and benchmarks.
**Alex:** One important thing: you'll need to work on this in a worktree, since another agent uses the main branch.
**Fable5:** Created a worktree and moved all work there.
**Alex:** Do all of the work in that worktree. Once you're done, create a branch - call it feat/proxy-method-slots - commit there, and create a PR I can review.
**Fable5:** Renamed the worktree branch to feat/proxy-method-slots; PR creation later turned out to be blocked on GitHub auth (no PR-scoped token), so the branch is pushed and the PR body is prepared at tmp/pr-body.md.
**Alex:** Also, there are proxy-related benchmarks. Run them before you start working, run them after, and tell me the improvement in the end. (They compare Fusion proxies to Castle.DynamicProxy - here I care about Fusion proxies only, of course.)
**Fable5:** Captured the baseline (cached compute: Long 36.5 ns, String 46.5 ns; raw dispatch: e.g. Void() 155M ops/s), then implemented the plan: ProxyMethodTable + slot-carrying Invocation, InterceptorBinding with per-slot handler resolution and a dynamic fallback, slot-based generated dispatch, and compute-interceptor opt-ins. All 921 tests passed; cached compute got 22-25% faster, raw dispatch 1.3-3.7x faster.
**Alex:** Check if there are any examples of proxy code in the documentation and update that code. Don't touch old presentations - you can modify just the new one, if there is such code - plus any .md/.cs files in the docs folder.
**Fable5:** Updated PartAP-PG.md (both generated-proxy examples), PartAP-D.md (fields/Invocation tables), two call-flow/type-hierarchy SVGs, and the plan docs' status lines; the proxy code sample exists only in the old slide deck (fusion-intro-v1), which was left untouched.
**Alex:** Explain one thing: am I right that any proxy has a proxy method table? And if yes, why do we need an Invocation variant that caches MethodInfo?
**Fable5:** Every generated proxy has a static table; the MethodInfo-based Invocation ctor existed only as a compatibility path for hand-written proxies and assemblies generated by older Fusion versions.
**Alex:** Now we need to fix that: update hand-written proxies and the related tests, and don't worry about older Fusion versions - if old generated code would fail, you can't upgrade without regenerating proxies anyway, so get rid of the old variant completely. Every Invocation should carry the method table and index explicitly (assigning the table directly is slightly faster than getting it via an interface). Also eliminate the range check on the method table: the Invocation constructor does the range check once, and code using the index relies on plain pointer math, free of range checks.
**Fable5:** Removed the legacy ctor, made (MethodTable, MethodIndex) mandatory with the single ctor-time range check, switched Method resolution and binding reads to unchecked access, and updated AltClassProxy + docs; cached compute improved to -22..-26% vs baseline.
**Alex:** On InterceptorBinding and IsDynamic: assuming interceptors are invoked only from proxies with method tables - can we implement efficient handler caching without relying on IsDynamic? Can we store the tables in the interceptors themselves? Basically: is there a way to completely get rid of dictionary lookups and simultaneously drop the weird IsDynamic/binding machinery?
**Fable5:** The warm path was already dictionary-free, but IsDynamic could die by contract: make SelectHandler once-per-slot, move per-call logic inside handlers, and delete the MethodInfo-keyed handler cache outright.
**Alex:** Let's think about this a bit more. Right now proxy methods invoke the interceptor, which finds the handler and invokes it. Can we extract that logic - make the overridden proxy methods do a two-step process: first trigger handler lookup, but only if there's no cached handler, then invoke it? The cached handler would be per proxy instance - fatter proxies, one dedicated field per slot - ideally with the fields ordered so a span can clear all cached handlers on interceptor rebinding.
**Fable5:** Feasible and strictly better: per-slot __handlerN fields as L1, the binding as shared L2; flagged that a span over fields isn't layout-guaranteed (proposed an unrolled clear or [InlineArray] on net8+) and that rebinding introduces a stale-cache race needing ordered writes.
**Alex:** My goal here is a simple interceptor API without pollution like weird double cache lookups, and more efficient logic overall.
**Fable5:** Implemented it: SelectHandler runs at most once per (interceptor, table, slot), GetHandler/_handlerCache/InterceptorExt/IsDynamic deleted, per-instance handler fields added, and a NoHandler marker lets pass-through calls invoke the typed original delegate directly (fixing a boxing regression the first cut introduced). Final: cached compute -26..-37% vs baseline, dispatch 1.9-4.3x faster.
**Alex:** Don't run all tests just yet - commit, and let me see the changes.
**Fable5:** Stopped the suites and pushed; the true diff vs the branch base is 19 files, +742/-237.
**Alex:** On CreateHandler vs GetHandler: does it make sense to still cache resolved handlers in interceptors (per proxy type) by default? Two cases: on a server, proxies are created frequently for UI-scoped compute services in Blazor Server; on a device there's a single scope, so proxy instances are typically created once and we don't need the cache. I'd consider a static-property gate enabling the in-interceptor handler cache, false by default. Does that make sense?
**Fable5:** Recommended against the gate: the shared cache is per proxy type, not per instance, so devices waste only kilobytes, while a default-off gate would silently cost Blazor Server per-circuit reflection.
**Alex:** Sorry, maybe I didn't describe it right. Previously we had a mechanism that caches handlers - does it make sense to still have it, but optional (enabled for server scenarios only), to save on MakeGenericType and reflection-based handler activation?
**Fable5:** That caching never went away - it moved into the binding's per-slot array with the same cardinality as the old dictionary, always on; new scoped proxies fill their fields from it with a plain array read, so reflection runs once per (interceptor, type, slot) per process.
**Alex:** Can you quickly draw a picture - a schema showing the different caching spots and their cardinality for these handlers?
**Fable5:** Sent a diagram: L1 per-instance fields (N instances x K slots), L2 binding array (1 handler per slot per proxy type), handler factory (runs once per slot; the only reflection spot), and the bind-time-only table -> binding map.
**Alex:** We don't clear L1 on the very first interceptor assignment, right?
**Fable5:** We did, but it was a no-op (nulls over nulls); keeping it unconditional kept the rebind race-proof simple.
**Alex:** "You'd have to re-derive that" - what do you mean?
**Fable5:** Showed that the "obvious" guarded clear is actually unsound (racing first-binds can permanently cache a stale handler), i.e., any change to the rebind sequence requires redoing the interleaving analysis.
**Alex:** Honestly, I don't understand what you're talking about - let's implement a way simpler guard: we should never allow reassigning the interceptor. It can be bound just once, right after construction, and no proxy with an unbound interceptor should ever reach user code (check the existing binding places - I think that's the conclusion you'll make). Your Volatile.Write looks awful to me; in reality we shouldn't reset the handler fields at all - they're null on construction and assigned later. And the code setting these fields shouldn't heavily optimize for concurrency: if a proxy is shared by two threads, it's fine if one doesn't instantly see a handler the other cached, because there's the L2 cache. Long story short: optimize for reads, not writes, and for overall consistency.
**Fable5:** Verified all binding sites bind exactly once right after construction, made the setter throw on rebinding, and deleted the volatile write, the field clearing, and the barrier + re-check - handler fields now only ever go from null to the single published handler via plain stores.
**Alex:** Give ResolveHandler a better name - GetAndCacheHandler (it uses the Get -> Resolve chain and caches). Also, am I right there's no need to manually eliminate array bound checks now, since all hot-path lookups are essentially field lookups?
**Fable5:** Renamed it, and - agreeing at first - removed all unchecked array access.
**Alex:** Wait, WTF on the proxy method table? Methods still CAN be resolved frequently by index, no?
**Fable5:** Right - Invocation.Method can run per call in handler code (logging, NoHandler), so restored GetMethodUnchecked there, relying on the ctor's one-time validation; the binding's L1-miss read stays plainly checked.
**Alex:** Also, does the proxy API provide access to the current binding?
**Fable5:** It didn't - only IProxy.Interceptor; proposed exposing the binding (and thereby the method table) on IProxy.
**Alex:** I'd prefer just `public InterceptorBinding Binding { get; set; }` and no direct interceptor access via that interface. It's fine to break the old API. Also - Func<Invocation, object?>: isn't it better to have Func<Invocation, Delegate?> everywhere? And the "Binding" name is fine only if it's visible only via the interface - no public Binding member on proxy types.
**Fable5:** Reshaped IProxy to MethodTable { get; } + Binding { get; set; } (both explicit interface implementations, so nothing is on the proxy's public surface; the setter validates the table match, and BindTo composes the two). Recommended keeping Func<Invocation, object?>: the hot built-ins are deliberately untyped, Task<T> results gain nothing from a delegate-side cast, and the real ValueTask<T> boxing lives deeper in the result plumbing.
**Alex:** `private volatile ConcurrentDictionary<ProxyMethodTable, InterceptorBinding>? _bindings` - why is it volatile?
**Fable5:** No good reason - Interlocked.CompareExchange already guarantees publication and a stale null read is benign - so removed volatile from both lazily created caches.
**Alex:** OK, and what are _aliasIndexes?
**Fable5:** The cache behind GetIndex for alias MethodInfo identities (base-class declarations, equivalent interface declarations, and -1 for foreign methods) - a cold-path reverse-lookup helper.
**Alex:** Got it - add a brief comment on that near its declaration.
**Fable5:** Added.
**Alex:** Besides that, I'd make it readonly too, and always assign it - it's fine to have one per type.
**Fable5:** Made it eager and readonly; GetIndex collapsed to a single expression.
**Alex:** Now squash all commits in this branch into one, and add a polished log of our interactions to its description.
**Fable5:** This commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mCEGf9t1SAXh8jQYbLSjL1 parent 19f89bf commit 2780de3
23 files changed
Lines changed: 757 additions & 247 deletions
File tree
- docs
- img/diagrams
- plans
- src
- ActualLab.Fusion
- Client/Interception
- Interception
- ActualLab.Generators
- Internal
- ActualLab.Interception
- Internal
- tests/ActualLab.Tests
- Generators
- Interception
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
26 | 28 | | |
27 | | - | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
36 | | - | |
| 37 | + | |
| 38 | + | |
37 | 39 | | |
38 | 40 | | |
39 | 41 | | |
| |||
64 | 66 | | |
65 | 67 | | |
66 | 68 | | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
67 | 77 | | |
68 | 78 | | |
69 | 79 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
41 | | - | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
42 | 44 | | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
47 | 61 | | |
48 | 62 | | |
49 | 63 | | |
| |||
56 | 70 | | |
57 | 71 | | |
58 | 72 | | |
59 | | - | |
| 73 | + | |
60 | 74 | | |
61 | | - | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
62 | 79 | | |
63 | 80 | | |
64 | 81 | | |
| |||
84 | 101 | | |
85 | 102 | | |
86 | 103 | | |
87 | | - | |
88 | | - | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
89 | 107 | | |
90 | | - | |
91 | | - | |
| 108 | + | |
| 109 | + | |
92 | 110 | | |
93 | | - | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
94 | 117 | | |
95 | 118 | | |
96 | 119 | | |
| |||
103 | 126 | | |
104 | 127 | | |
105 | 128 | | |
106 | | - | |
| 129 | + | |
107 | 130 | | |
108 | | - | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
109 | 135 | | |
110 | 136 | | |
111 | 137 | | |
| |||
120 | 146 | | |
121 | 147 | | |
122 | 148 | | |
123 | | - | |
| 149 | + | |
| 150 | + | |
124 | 151 | | |
125 | | - | |
| 152 | + | |
126 | 153 | | |
127 | 154 | | |
128 | 155 | | |
| |||
Loading
Loading
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
94 | 97 | | |
95 | 98 | | |
96 | 99 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
6 | 11 | | |
7 | 12 | | |
8 | 13 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
410 | 410 | | |
411 | 411 | | |
412 | 412 | | |
413 | | - | |
| 413 | + | |
414 | 414 | | |
415 | 415 | | |
416 | 416 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
Lines changed: 1 addition & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
| 40 | + | |
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
41 | 40 | | |
42 | 41 | | |
43 | 42 | | |
44 | 43 | | |
45 | 44 | | |
46 | 45 | | |
47 | 46 | | |
48 | | - | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
49 | 55 | | |
50 | 56 | | |
51 | 57 | | |
52 | | - | |
53 | | - | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
54 | 63 | | |
| 64 | + | |
| 65 | + | |
55 | 66 | | |
56 | 67 | | |
57 | 68 | | |
| |||
60 | 71 | | |
61 | 72 | | |
62 | 73 | | |
63 | | - | |
64 | | - | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
65 | 78 | | |
66 | 79 | | |
67 | 80 | | |
| 81 | + | |
68 | 82 | | |
69 | 83 | | |
70 | 84 | | |
| |||
136 | 150 | | |
137 | 151 | | |
138 | 152 | | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
139 | 156 | | |
140 | 157 | | |
141 | 158 | | |
| |||
0 commit comments