From cbd846bb6eb227eeafa81d3827e5ec1b99fb6048 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 20 Jun 2026 14:45:33 -0700 Subject: [PATCH 1/4] Perf #861: de-virtualize annotated-param array HOF callbacks Part of #856 (perf epic). The array HOFs (map/filter/reduce/forEach/find/some/ every) already had a direct-delegate fast path (TryEmitDirectDelegateCall -> Array*Direct(List, Func)), but it bailed at `if (p.Type != null) return false`: an annotated callback like `(x: number): number => x*2` compiles to a typed static method (double(double)) that cannot bind to Func, so it fell back to the reflective ArrayMap + $TSFunction/MethodInvoker per-element path (MethodInvoker.Invoke + a 3-element object[] alloc + boxing per element). Idiomatic TypeScript annotates callback params, so the array-methods benchmark hit the reflective path on every callback -- the same reflective-dispatch class #858 just won ~40x on, not boxing. Fix: a new ArrowBoxedAdapterEmitter emits a per-arrow boxed adapter object(object[,object]) that unboxes/casts each boxed element into the arrow's typed parameter slot, calls the typed arrow, then reboxes the result. It binds to the existing Func/Func and feeds the unchanged Array*Direct helpers -- so the per-element MethodInvoker dispatch is gone. The unbox/box marshalling reuses DelegateAdapterEmitter.EmitUnboxForReturn/ EmitBoxForTS (now internal static), which matches the reflective MethodInvoker's no-arg-conversion regime exactly for concrete double/bool/string params (unions/ nullable already widen to object in ParameterTypeResolver). The adapter is emitted onto the arrow's staticMethod.DeclaringType (the $Program TypeBuilder), NOT ctx.ProgramType -- ProgramType is set only on the module-top- level context, so keying on it would have gated the optimization to top-level and left the in-function benchmark calls reflective. Gated conservatively: non- capturing arrows only (capturing defers to the reflective path, a follow-up), marshallable param/return types only. arrayMethodWork@10k warm (Release): ~3.31 -> ~1.22 ms/call (~2.7x), identical output, zero reflective dispatch / object[] per element; chained map().filter().reduce() emits 3 adapters, 0 ldtoken. Standalone-DLL constraint preserved (adapter is an emitted static method, no SharpTS.dll reference). Deferred to follow-ups: chained-stage List<->$Array round-trip elimination (L2), capturing annotated arrows (L3), bool-return adapter -> *DirectBool (L4). 24 new dual-mode tests (ArrayHofAnnotatedCallbackTests). Full dotnet test green except known flaky (UsingDeclaration/NumericSeparator pass in isolation) and the stale Test262 baselines (the only Test262 "regressions" are Array/isArray TypeCheckError/Proxy drift that also appears in interpreter mode -- a compiled- only change cannot cause those). --verify clean. --- Compilation/ArrowBoxedAdapterEmitter.cs | 112 ++++++++++ Compilation/CompilationContext.Closures.cs | 7 + Compilation/DelegateAdapterEmitter.cs | 16 +- Compilation/Emitters/ArrayEmitter.cs | 150 ++++++++++--- .../ArrayHofAnnotatedCallbackTests.cs | 210 ++++++++++++++++++ 5 files changed, 461 insertions(+), 34 deletions(-) create mode 100644 Compilation/ArrowBoxedAdapterEmitter.cs create mode 100644 SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs diff --git a/Compilation/ArrowBoxedAdapterEmitter.cs b/Compilation/ArrowBoxedAdapterEmitter.cs new file mode 100644 index 00000000..78e33e36 --- /dev/null +++ b/Compilation/ArrowBoxedAdapterEmitter.cs @@ -0,0 +1,112 @@ +using System.Reflection; +using System.Reflection.Emit; + +namespace SharpTS.Compilation; + +/// +/// Emits per-arrow "boxed adapter" static methods that let an arrow with +/// annotated parameters be dispatched by the array HOF direct-delegate +/// fast path (#861). +/// +/// +/// +/// An annotated callback like (x: number): number => x*2 compiles to a +/// static method with the typed CLR signature double(double), which cannot +/// bind to the Func<object,object> that the emitted +/// Array*Direct helpers expect. Without a bridge, such callbacks fall back +/// to the reflective $TSFunction/MethodInvoker path (per-element +/// dispatch + an object[] allocation), which is exactly the cost #861 +/// targets. +/// +/// +/// The adapter is a static method object Adapter(object[, object]) on +/// $Program whose body unboxes/casts each boxed element into the arrow's +/// typed parameter slot, calls the typed arrow method, then reboxes the +/// result. It binds to Func<object,object> / +/// Func<object,object,object> via ldnull+ldftn, so the +/// unchanged Array*Direct helpers drive it with a direct delegate call. +/// +/// +/// The unbox/box marshalling is shared with +/// ( coerces +/// object→typed slot; +/// reboxes the typed result), so the conversion matches the reflective +/// MethodInvoker semantics for the no-arg-conversion regime (concrete +/// double/bool/string params — unions/nullable already widen +/// to object in ParameterTypeResolver, and the call site gates on +/// that). Only emits a static adapter, so it carries no SharpTS.dll +/// reference (standalone-DLL constraint preserved). +/// +/// +internal sealed class ArrowBoxedAdapterEmitter +{ + // Keyed by (typed arrow method, adapter arity). Arity is the delegate's + // parameter count (1 for map/filter/forEach/find/…, 2 for reduce); the arrow + // itself may declare fewer params, in which case the extra adapter args are + // ignored. A given arrow node is emitted by exactly one CompilationContext (the + // one containing its call site), so this per-context cache never double-defines; + // the adapter NAME is derived from the arrow's globally-unique method name so it + // stays collision-free across contexts that share the same $Program type. + private readonly Dictionary<(MethodBuilder, int), MethodBuilder> _cache = []; + + /// + /// Returns the boxed adapter for bound to a + /// delegate of object parameters, emitting it on + /// (the arrow's declaring $Program type) + /// on first request. + /// + public MethodBuilder GetOrEmit(TypeBuilder programType, MethodBuilder typedArrow, int funcArity) + { + var key = (typedArrow, funcArity); + if (_cache.TryGetValue(key, out var existing)) return existing; + + var objectType = typeof(object); + var adapterParams = new Type[funcArity]; + for (int i = 0; i < funcArity; i++) adapterParams[i] = objectType; + + // Assembly-visible static, matching the arrow methods on $Program it calls + // into. Name keyed off the arrow's unique method name (e.g. <>Arrow_5) so + // two contexts emitting adapters onto the same $Program never collide. + var adapter = programType.DefineMethod( + $"{typedArrow.Name}$box{funcArity}", + MethodAttributes.Assembly | MethodAttributes.Static, + objectType, + adapterParams); + + var il = adapter.GetILGenerator(); + var arrowParams = typedArrow.GetParameters(); + + // Load only as many args as the arrow actually declares, coercing each + // boxed object into its typed parameter slot. A 0-/1-param arrow under a + // 2-arg delegate (or 1-arg delegate) simply ignores the surplus args. + for (int i = 0; i < arrowParams.Length; i++) + { + EmitLdarg(il, i); + DelegateAdapterEmitter.EmitUnboxForReturn(il, arrowParams[i].ParameterType); + } + + il.Emit(OpCodes.Call, typedArrow); + + // Rebox the typed result back to object for the Func contract. + DelegateAdapterEmitter.EmitBoxForTS(il, typedArrow.ReturnType); + il.Emit(OpCodes.Ret); + + _cache[key] = adapter; + return adapter; + } + + private static void EmitLdarg(ILGenerator il, int index) + { + switch (index) + { + case 0: il.Emit(OpCodes.Ldarg_0); break; + case 1: il.Emit(OpCodes.Ldarg_1); break; + case 2: il.Emit(OpCodes.Ldarg_2); break; + case 3: il.Emit(OpCodes.Ldarg_3); break; + default: + if (index <= 255) il.Emit(OpCodes.Ldarg_S, (byte)index); + else il.Emit(OpCodes.Ldarg, index); + break; + } + } +} diff --git a/Compilation/CompilationContext.Closures.cs b/Compilation/CompilationContext.Closures.cs index b99b8631..89a0b05d 100644 --- a/Compilation/CompilationContext.Closures.cs +++ b/Compilation/CompilationContext.Closures.cs @@ -16,6 +16,13 @@ public partial class CompilationContext // Arrow function methods (arrow node -> method info) public Dictionary ArrowMethods { get; set; } = []; + // Boxed-callback adapters for annotated-param array HOF callbacks (#861). + // Lazily created (no constructor plumbing needed); emits per-arrow + // object(object[,object]) adapters on $Program so a typed arrow can bind to + // the Func the Array*Direct helpers expect. + private ArrowBoxedAdapterEmitter? _arrowBoxedAdapters; + internal ArrowBoxedAdapterEmitter ArrowBoxedAdapters => _arrowBoxedAdapters ??= new ArrowBoxedAdapterEmitter(); + // Module-scope const → literal-arrow bindings. Iterator-helper fast paths // look up `Expr.Variable` callbacks here so `const sq = x => x*x; arr.map(sq)` // gets the same direct-delegate dispatch as the inline-arrow form. diff --git a/Compilation/DelegateAdapterEmitter.cs b/Compilation/DelegateAdapterEmitter.cs index 27c9c1da..85900520 100644 --- a/Compilation/DelegateAdapterEmitter.cs +++ b/Compilation/DelegateAdapterEmitter.cs @@ -159,7 +159,13 @@ private MethodBuilder EmitInvoke( /// TS number, bool stays boxed bool, reference types pass through. /// Mirrors the interpreter's DotNetMarshaller.WrapReturn for the common primitives. /// - private void EmitBoxForTS(ILGenerator il, Type paramType) + /// + /// internal static so the boxed-callback adapter emitter for array HOFs + /// (, #861) shares the exact same + /// box/unbox conventions — keeping number↔boxed-double etc. + /// consistent across every emitted marshalling site. + /// + internal static void EmitBoxForTS(ILGenerator il, Type paramType) { if (!paramType.IsValueType) { @@ -209,7 +215,13 @@ private void EmitBoxForTS(ILGenerator il, Type paramType) /// delegate's declared return type. void: pop. object: no-op. value types: unbox via /// boxed-double (matching ). Reference types: castclass. /// - private void EmitUnboxForReturn(ILGenerator il, Type returnType) + /// + /// internal static: also used by + /// (#861) to coerce a boxed array element into an arrow's typed parameter slot + /// (the inverse role), so the unbox/cast matches the reflective + /// MethodInvoker path exactly. + /// + internal static void EmitUnboxForReturn(ILGenerator il, Type returnType) { if (returnType == typeof(void)) { diff --git a/Compilation/Emitters/ArrayEmitter.cs b/Compilation/Emitters/ArrayEmitter.cs index 9f72c797..efabab3e 100644 --- a/Compilation/Emitters/ArrayEmitter.cs +++ b/Compilation/Emitters/ArrayEmitter.cs @@ -781,44 +781,115 @@ private static bool TryEmitDirectDelegateCall(IEmitterContext emitter, List 1) return false; foreach (var p in af.Parameters) { - if (p.Type != null) return false; + // Annotated params (p.Type != null) are now supported via a boxed + // adapter (#861) — see the typed-param path below. Rest/optional/ + // defaulted params still bail (they need the reflective arg shape). if (p.IsRest || p.IsOptional) return false; if (p.DefaultValue != null) return false; } if (!ctx.ArrowMethods.TryGetValue(af, out var staticMethod)) return false; - // Static method's parameter types are guaranteed to be `object` by the - // AST-no-annotation check (ParameterTypeResolver falls back to object). - // Return type is the discriminator: type inference may yield `object` - // (any-typed bodies) or `bool` (predicate bodies like `v => v > 10`). - // The first uses Func; the second uses Func - // where the helper variant exists. Phase B: capturing arrows now go - // through emitter.TryEmitArrowAsDelegate, which builds the display - // instance + binds the delegate to its Invoke method. - var ret = staticMethod.ReturnType; - System.Reflection.Emit.MethodBuilder chosenHelper; - Type funcType; - if (ret == ctx.Types.Object) - { - chosenHelper = helperMethod; - funcType = typeof(Func); - } - else if (ret == ctx.Types.Boolean && boolHelper != null) - { - chosenHelper = boolHelper; - funcType = typeof(Func); - } - else + var sParams = staticMethod.GetParameters(); + bool allParamsObject = true; + foreach (var sp in sParams) + if (sp.ParameterType != ctx.Types.Object) { allParamsObject = false; break; } + + if (allParamsObject) { - return false; + // Untyped fast path (unchanged). The arrow's static method already has + // `object` parameter slots (unannotated params; ParameterTypeResolver + // falls back to object), so it binds directly to Func. The + // return type is the discriminator: `object` (any-typed body) uses + // Func + helperMethod; `bool` (predicate body like + // `v => v > 10`) uses Func + boolHelper where present. + // Capturing arrows go through emitter.TryEmitArrowAsDelegate, which + // builds the display instance + binds to its Invoke method. + var ret = staticMethod.ReturnType; + System.Reflection.Emit.MethodBuilder chosenHelper; + Type funcType; + if (ret == ctx.Types.Object) + { + chosenHelper = helperMethod; + funcType = typeof(Func); + } + else if (ret == ctx.Types.Boolean && boolHelper != null) + { + chosenHelper = boolHelper; + funcType = typeof(Func); + } + else + { + return false; + } + + if (!emitter.TryEmitArrowAsDelegate(af, funcType)) + return false; + ctx.IL.Emit(OpCodes.Call, chosenHelper); + return true; } - if (!emitter.TryEmitArrowAsDelegate(af, funcType)) + // Typed-param adapter path (#861): an annotated callback compiles to a + // typed static method (e.g. double(double)/bool(double)) that cannot bind + // to Func directly. Bind a per-arrow boxed adapter that + // marshals object↔typed around the arrow call, then drive the existing + // object-callback helper. The boxed bool predicate result is handled by + // the helper's own IsTruthy (so the boolHelper/Direct-bool variant is a + // later refinement, #861 L4). + if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 1)) return false; - ctx.IL.Emit(OpCodes.Call, chosenHelper); + ctx.IL.Emit(OpCodes.Call, helperMethod); return true; } + /// + /// Binds a per-arrow boxed adapter (object(object[,object])) for an annotated, + /// non-capturing callback arrow to a Func<object,…> of + /// parameters, leaving the delegate on the stack + /// for the caller to pass to an Array*Direct helper. Returns false (reflective + /// fallback) for capturing arrows or any non-marshallable param/return type. + /// + private static bool TryBindTypedArrowAdapter( + IEmitterContext emitter, + Expr.ArrowFunction af, + System.Reflection.Emit.MethodBuilder staticMethod, + int funcArity) + { + var ctx = emitter.Context; + // Capturing arrows (display class) need an instance receiver — defer to the + // reflective path (#861 L3). + if (ctx.DisplayClasses.ContainsKey(af)) return false; + // The adapter is emitted onto the arrow's declaring $Program type. Using the + // arrow's DeclaringType (rather than ctx.ProgramType, which is only set on + // the module-top-level context) lets the adapter path fire inside function + // and method bodies too — the dominant case for the array-methods benchmark. + if (staticMethod.DeclaringType is not TypeBuilder programType) return false; + + // Marshallable gate: stay in the reflective path's no-arg-conversion regime + // (concrete double/bool/string, or object) so the adapter's unbox/cast + // matches MethodInvoker semantics exactly. Union/nullable params already + // widen to object in ParameterTypeResolver, so a non-object slot here is a + // concrete value/reference type. + if (!IsAdapterMarshallable(ctx, staticMethod.ReturnType)) return false; + foreach (var sp in staticMethod.GetParameters()) + if (!IsAdapterMarshallable(ctx, sp.ParameterType)) return false; + + var adapter = ctx.ArrowBoxedAdapters.GetOrEmit(programType, staticMethod, funcArity); + Type funcType = funcArity == 2 + ? typeof(Func) + : typeof(Func); + var funcCtor = funcType.GetConstructor([typeof(object), typeof(IntPtr)])!; + + // Non-capturing static adapter: target = null, ldftn the adapter. + ctx.IL.Emit(OpCodes.Ldnull); + ctx.IL.Emit(OpCodes.Ldftn, adapter); + ctx.IL.Emit(OpCodes.Newobj, funcCtor); + return true; + } + + private static bool IsAdapterMarshallable(CompilationContext ctx, Type t) + => t == ctx.Types.Object || t == ctx.Types.Double + || t == ctx.Types.Boolean || t == ctx.Types.String; + /// /// Reduce-specific fast path: the callback is binary /// (acc, element) => newAcc, requires Func<object, object, @@ -839,17 +910,32 @@ private static bool TryEmitReduceDirectCall(IEmitterContext emitter, List if (af.Parameters.Count != 2) return false; foreach (var p in af.Parameters) { - if (p.Type != null) return false; + // Annotated params now supported via a boxed adapter (#861). if (p.IsRest || p.IsOptional) return false; if (p.DefaultValue != null) return false; } if (!ctx.ArrowMethods.TryGetValue(af, out var staticMethod)) return false; - if (staticMethod.ReturnType != ctx.Types.Object) return false; - var func3 = typeof(Func); - // Stack: [list]. Build delegate (capturing or non-capturing). - if (!emitter.TryEmitArrowAsDelegate(af, func3)) - return false; + var sParams = staticMethod.GetParameters(); + bool allObject = staticMethod.ReturnType == ctx.Types.Object; + if (allObject) + foreach (var sp in sParams) + if (sp.ParameterType != ctx.Types.Object) { allObject = false; break; } + + // Stack: [list]. Build the (acc, element) => newAcc delegate. + if (allObject) + { + // Untyped fast path (unchanged): object(object,object) binds directly. + if (!emitter.TryEmitArrowAsDelegate(af, typeof(Func))) + return false; + } + else + { + // Annotated reducer (e.g. double(double,double)): bridge via a boxed + // adapter object(object,object). + if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 2)) + return false; + } // Push initial value (boxed object). emitter.EmitExpression(arguments[1]); emitter.EmitBoxIfNeeded(arguments[1]); diff --git a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs new file mode 100644 index 00000000..a787a5c7 --- /dev/null +++ b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs @@ -0,0 +1,210 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.SharedTests; + +/// +/// Tests for #861: array higher-order-function callbacks (map/filter/reduce/forEach/ +/// find/some/every) whose arrow has ANNOTATED parameters are de-virtualized in compiled +/// mode. An annotated callback like (x: number): number => x*2 compiles to a typed +/// static method (double(double)) that cannot bind to the Func<object,object> +/// the Array*Direct helpers expect; a per-arrow boxed adapter bridges it, so the +/// per-element reflective $TSFunction/MethodInvoker dispatch is removed. +/// +/// These run against BOTH the interpreter and the compiler. The point is interpreter/compiled +/// parity: the adapter's unbox/box must match the reflective path exactly, and the cases that +/// fall back (capturing arrows, multi-arg callbacks) must still produce correct results. A +/// wrong adapter coercion or a mis-fired fast path surfaces here as a compiled-mode mismatch. +/// +public class ArrayHofAnnotatedCallbackTests +{ + // ── Adapter path: annotated callbacks inside a function (the benchmark shape) ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedMap_Number(ExecutionMode mode) + { + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map((x: number): number => x * 2).join(","); + } + console.log(f()); + """; + Assert.Equal("2,4,6,8,10\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedFilter_BooleanPredicate(ExecutionMode mode) + { + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5, 6]; + return a.filter((x: number): boolean => x % 2 === 0).join(","); + } + console.log(f()); + """; + Assert.Equal("2,4,6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedReduce_TwoTypedParams(ExecutionMode mode) + { + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + return a.reduce((acc: number, x: number): number => acc + x, 0); + } + console.log(f()); + """; + Assert.Equal("15\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedChain_MapFilterReduce(ExecutionMode mode) + { + // The array-methods benchmark shape: every callback param is annotated. + var source = """ + function arrayMethodWork(n: number): number { + const arr: number[] = []; + for (let i: number = 0; i < n; i++) { arr.push(i); } + const doubled = arr.map((x: number): number => x * 2); + const evens = doubled.filter((x: number): boolean => x % 4 === 0); + return evens.reduce((acc: number, x: number): number => acc + x, 0); + } + console.log(arrayMethodWork(10)); + """; + Assert.Equal("40\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedPredicates_FindSomeEveryFindIndex(ExecutionMode mode) + { + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + const found = a.find((x: number): boolean => x > 3); + const hasBig = a.some((x: number): boolean => x > 4); + const allPos = a.every((x: number): boolean => x > 0); + const idx = a.findIndex((x: number): boolean => x === 3); + return found + "," + hasBig + "," + allPos + "," + idx; + } + console.log(f()); + """; + Assert.Equal("4,true,true,2\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedForEach_Void(ExecutionMode mode) + { + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4]; + let sum: number = 0; + a.forEach((x: number): void => { sum = sum + x; }); + return sum; + } + console.log(f()); + """; + Assert.Equal("10\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedStringParam_MapsToLength(ExecutionMode mode) + { + var source = """ + function f(): string { + const s: string[] = ["ab", "c", "defg"]; + return s.map((w: string): number => w.length).join(","); + } + console.log(f()); + """; + Assert.Equal("2,1,4\n", TestHarness.Run(source, mode)); + } + + // ── Coercion parity: non-integer / NaN elements must marshal identically ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void AnnotatedMap_FloatAndNaN(ExecutionMode mode) + { + var source = """ + function f(): string { + const b: number[] = [1.5, NaN, 3]; + return b.map((x: number): number => x + 1).join(","); + } + console.log(f()); + """; + Assert.Equal("2.5,NaN,4\n", TestHarness.Run(source, mode)); + } + + // ── Fallback cases: must NOT take the 1-arg adapter, must stay correct ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void MultiArgCallback_UsesIndex_FallsBack(ExecutionMode mode) + { + // 2-param map callback uses the index → arity guard keeps it on the reflective + // path; result must still be correct. + var source = """ + function f(): string { + const a: number[] = [10, 20, 30]; + return a.map((x: number, i: number): number => x + i).join(","); + } + console.log(f()); + """; + Assert.Equal("10,21,32\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_FallsBack(ExecutionMode mode) + { + // Capturing arrow → adapter path is skipped (needs an instance receiver); + // reflective fallback must still produce the captured value correctly. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3]; + const k: number = 10; + return a.map((x: number): number => x + k).join(","); + } + console.log(f()); + """; + Assert.Equal("11,12,13\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void UntypedCallback_StillWorks(ExecutionMode mode) + { + // Unannotated callback keeps the pre-existing untyped direct/reflective path. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map(x => x + 1).join(","); + } + console.log(f()); + """; + Assert.Equal("2,3,4,5,6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ConstBoundAnnotatedCallback_Resolves(ExecutionMode mode) + { + // const-bound arrow callback resolves through ConstArrowBindings and takes the + // adapter path when annotated. + var source = """ + const dbl = (x: number): number => x * 2; + const out = [3, 4, 5].map(dbl).join(","); + console.log(out); + """; + Assert.Equal("6,8,10\n", TestHarness.Run(source, mode)); + } +} From 0fce4140c7974975a3003451f8a3fc7aff3ff685 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 20 Jun 2026 15:07:29 -0700 Subject: [PATCH 2/4] Perf #861 (L2): eliminate chained-stage List<->$Array round-trip Follow-up to the #861 Layer 1 boxed-adapter work. A chained array expression like arr.map(f).filter(g).reduce(h) wrapped each intermediate result back into a $Array (EmitPostCallAdjust, returnsNewArray) only for the next stage to immediately unwrap it to a List again (EmitGetListFromArrayOrList). The intermediate array is anonymous -- it can only ever be the receiver of the one following array-method call -- so its $Array identity is unobservable. TryEmitMethodCall now threads a leaveResultAsBareList flag: when a call's receiver is itself a fresh-array-producing array method on an array receiver (TryEmitChainedArrayReceiverAsBareList), the inner call is emitted with its $Array wrap suppressed (bare List left on the stack) and the outer call skips the unwrap. The flag recurses, so a 3-stage chain drops both intermediate boundaries while the FINAL stage still wraps to $Array when its result is consumed as an array value (.length, assignment, ===). Disabled for returnsReceiver methods (sort/ reverse/fill/copyWithin, which need the original wrapper) and the await-spill path. The benchmark's map().filter().reduce() now emits zero newobj $Array / get_Elements between stages (IL-verified). Warm throughput is flat (~1.4 ms/call, identical to L1 in a controlled A/B) -- the win is allocation/GC reduction and cold-start, not warm steady-state, since per-element work dominates at large n. 8 new dual-mode chain tests (incl. final-wrap-preserved, map->slice plain-args boundary, mixed typed/untyped). Full dotnet test green except known flaky (pass in isolation) and the stale Test262 baselines (regressions are the same pre-existing Array/isArray TypeCheckError/Proxy drift, also present in interpreter mode -- unchanged by L2). --verify clean. --- Compilation/Emitters/ArrayEmitter.cs | 112 ++++++++++++------ .../ArrayHofAnnotatedCallbackTests.cs | 65 ++++++++++ 2 files changed, 144 insertions(+), 33 deletions(-) diff --git a/Compilation/Emitters/ArrayEmitter.cs b/Compilation/Emitters/ArrayEmitter.cs index efabab3e..54d70cf3 100644 --- a/Compilation/Emitters/ArrayEmitter.cs +++ b/Compilation/Emitters/ArrayEmitter.cs @@ -13,46 +13,62 @@ public sealed class ArrayEmitter : ITypeEmitterStrategy /// Attempts to emit IL for a method call on an array receiver. /// public bool TryEmitMethodCall(IEmitterContext emitter, Expr receiver, string methodName, List arguments) + => TryEmitMethodCall(emitter, receiver, methodName, arguments, leaveResultAsBareList: false); + + /// + /// Core array-method emitter. is set by the chained-stage + /// fast path (#861 L2): when this call's array result flows DIRECTLY into another array-method call, + /// the returnsNewArray $Array wrap is skipped so the bare List<object> feeds + /// straight into the next stage — eliminating the wrap-then-unwrap round-trip. + /// + private bool TryEmitMethodCall(IEmitterContext emitter, Expr receiver, string methodName, List arguments, bool leaveResultAsBareList) { var ctx = emitter.Context; var il = ctx.IL; - // Emit the array object - emitter.EmitExpression(receiver); - emitter.EmitBoxIfNeeded(receiver); - - // Handle both List and $Array types - // For $Array, extract the Elements property. - // The returned local holds the ORIGINAL receiver, used below for - // identity-preserving methods (sort/reverse/fill/copyWithin) and to - // wrap "new array" method results back into $Array so downstream - // code sees a $Array whenever the input was one. - var receiverLocal = EmitGetListFromArrayOrList(il, ctx); - - // Methods whose spec says "return this" — the caller expects the same - // reference the receiver started with. Since we unwrapped to a List, - // the runtime helper returns the inner List, not the $Array wrapper; - // to preserve `arr === arr.sort()` we stash the wrapper and push it - // back at the end. + // Methods whose spec says "return this" — the caller expects the same reference the receiver + // started with (sort/reverse/fill/copyWithin). Since we unwrap to a List, the helper returns the + // inner List, not the $Array wrapper; to preserve `arr === arr.sort()` we stash the wrapper and + // push it back at the end. bool returnsReceiver = methodName is "sort" or "reverse" or "fill" or "copyWithin"; - // Methods whose spec says "return a new Array" — we want callers to - // continue seeing a $Array after them (not a bare List), so - // downstream array methods / runtime dispatch still match. - bool returnsNewArray = methodName is - "slice" or "concat" or "map" or "filter" or "flat" or "flatMap" - or "splice" or "toReversed" or "toSorted" or "toSpliced" or "with"; + // Methods whose spec says "return a new Array" — callers should keep seeing a $Array after them + // (not a bare List), so downstream array methods / runtime dispatch still match. + bool returnsNewArray = IsReturnsNewArrayMethod(methodName); // #850: when an argument can suspend (await/yield), evaluating it while the receiver list sits - // on the IL evaluation stack produces invalid IL across a state-machine suspension - // (PathStackDepth — the stack differs between the "awaiter completed" and post-resume paths). - // Pre-spill the receiver and every argument into await-safe locals (registered so they survive - // the MoveNext re-entry; plain locals in the synchronous emitter, so non-async codegen is - // unchanged), then restore the list on the stack so each case below emits exactly as before but - // sources its arguments from the pre-spilled locals. Only the plain-argument methods are - // affected; the callback methods (map/filter/find/...) dispatch their arrow from the AST and - // never trigger this — an arrow body's await belongs to its own state machine. + // on the IL evaluation stack produces invalid IL across a state-machine suspension. Pre-spill the + // receiver + args into await-safe locals (callback methods never trigger this — an arrow body's + // await belongs to its own state machine). + bool plainArgSuspension = arguments.Count > 0 && MethodTakesPlainArgs(methodName) + && emitter.ArgsContainSuspension(arguments); + + // The receiver local holds the ORIGINAL receiver, used by returnsReceiver methods and the + // suspension spill below; unused for the other methods. + LocalBuilder receiverLocal; + + // #861 L2: if the receiver is itself an array-method call producing a fresh array via THIS + // emitter (e.g. the a.map(f) in a.map(f).filter(g)), emit it leaving a bare List (its + // $Array wrap suppressed) and skip the unwrap here — killing the round-trip at the boundary. The + // intermediate array is anonymous (it can only be this one call's receiver), so dropping its + // $Array identity is unobservable. Disabled when the outer method needs the original receiver + // wrapper (returnsReceiver) or pre-spills suspending plain args (the chained receiver would sit + // on the stack across the suspension). + if (!returnsReceiver && !plainArgSuspension + && TryEmitChainedArrayReceiverAsBareList(emitter, receiver)) + { + // Bare List already on the stack; receiverLocal is never read in this path. + receiverLocal = il.DeclareLocal(ctx.Types.ListOfObject); + } + else + { + // General path: emit the array object and unwrap $Array → List. + emitter.EmitExpression(receiver); + emitter.EmitBoxIfNeeded(receiver); + receiverLocal = EmitGetListFromArrayOrList(il, ctx); + } + LocalBuilder[]? argLocals = null; - if (arguments.Count > 0 && MethodTakesPlainArgs(methodName) && emitter.ArgsContainSuspension(arguments)) + if (plainArgSuspension) { var listSafe = emitter.SpillStackToObjectLocal(); // [list] -> await-safe local il.Emit(OpCodes.Ldloc, receiverLocal); // the original receiver must also @@ -355,10 +371,40 @@ public bool TryEmitMethodCall(IEmitterContext emitter, Expr receiver, string met return false; } - EmitPostCallAdjust(il, ctx, receiverLocal, returnsReceiver, returnsNewArray); + // #861 L2: when this result flows straight into a chained array call, skip the $Array wrap and + // leave the bare List on the stack for the next stage. + if (!(leaveResultAsBareList && returnsNewArray)) + EmitPostCallAdjust(il, ctx, receiverLocal, returnsReceiver, returnsNewArray); return true; } + /// The methods whose spec result is a freshly-allocated array (kept as a $Array for callers). + private static bool IsReturnsNewArrayMethod(string methodName) => methodName is + "slice" or "concat" or "map" or "filter" or "flat" or "flatMap" + or "splice" or "toReversed" or "toSorted" or "toSpliced" or "with"; + + /// + /// #861 L2 chained-stage detection. If is a call to a fresh-array- + /// producing array method on an array receiver (the a.map(f) in a.map(f).filter(g)), + /// emit that inner call leaving a bare List<object> on the stack — its $Array wrap + /// suppressed — and return true. The intermediate array is anonymous (it can only be this one outer + /// call's receiver), so dropping its $Array identity is unobservable. Returns false (emitting + /// nothing) otherwise. + /// + private bool TryEmitChainedArrayReceiverAsBareList(IEmitterContext emitter, Expr receiver) + { + if (receiver is not Expr.Call { Optional: false, Callee: Expr.Get { Optional: false } innerGet } innerCall) + return false; + string innerMethod = innerGet.Name.Lexeme; + // Must be a returnsNewArray method handled by THIS emitter (so it leaves a List on the stack). + if (!IsReturnsNewArrayMethod(innerMethod)) return false; + // The inner call's receiver must statically be an array, so the inner call goes through this + // emitter's returnsNewArray path and the recursion leaves a List (not some other value). + if (emitter.Context.TypeMap?.Get(innerGet.Object) is not SharpTS.TypeSystem.TypeInfo.Array) + return false; + return TryEmitMethodCall(emitter, innerGet.Object, innerMethod, innerCall.Arguments, leaveResultAsBareList: true); + } + /// /// After a call that leaves a List<object?> on the stack, adjust the /// top-of-stack so downstream code sees the expected JS value: diff --git a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs index a787a5c7..4ba81951 100644 --- a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs +++ b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs @@ -194,6 +194,71 @@ function f(): string { Assert.Equal("2,3,4,5,6\n", TestHarness.Run(source, mode)); } + // ── #861 L2: chained-stage round-trip elimination ── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedMapFilter_AssignedToArrayVariable(ExecutionMode mode) + { + // The final stage's result is consumed as an array value, so its $Array wrap must be + // preserved (only the intermediate map result drops it). `.join` then works on it. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + const r = a.map((x: number): number => x * 2).filter((x: number): boolean => x > 4); + return r.join(","); + } + console.log(f()); + """; + Assert.Equal("6,8,10\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedMapFilter_LengthOnResult(ExecutionMode mode) + { + // .length on the chain result requires the final $Array wrap to survive. + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map((x: number): number => x * 2).filter((x: number): boolean => x > 5).length; + } + console.log(f()); + """; + Assert.Equal("3\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedMapSlice_MixedCallbackAndPlainArgs(ExecutionMode mode) + { + // Inner map (callback) feeds outer slice (plain args) — the bare List must flow across the + // boundary and slice must consume it correctly. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5]; + return a.map((x: number): number => x * 2).slice(1, 3).join(","); + } + console.log(f()); + """; + Assert.Equal("4,6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void ChainedFilterMap_UntypedAndAnnotatedMixed(ExecutionMode mode) + { + // Inner filter is annotated, outer map is untyped — both stages chain through a bare List. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5, 6]; + return a.filter((x: number): boolean => x % 2 === 0).map(x => x * 10).join(","); + } + console.log(f()); + """; + Assert.Equal("20,40,60\n", TestHarness.Run(source, mode)); + } + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void ConstBoundAnnotatedCallback_Resolves(ExecutionMode mode) From 98e4d16f80de09671cc65830ccc9d7d422cebc67 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 20 Jun 2026 16:23:40 -0700 Subject: [PATCH 3/4] Perf #861 (L3): de-virtualize CAPTURING annotated array HOF callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers 1/2 handled non-capturing annotated callbacks. A capturing arrow (e.g. arr.map((x: number): number => x + k)) compiles to a typed INSTANCE Invoke on a display class, not a static $Program method, so L1's adapter path bailed (DisplayClasses.ContainsKey) and it fell back to per-element reflective $TSFunction/MethodInvoker dispatch. L3 emits the boxed adapter as an INSTANCE method on the arrow's display class (object Invoke$box(object[,object]) -> unbox -> callvirt this.Invoke -> box) and binds the delegate to (displayInstance, ldftn adapter). The display instance is built fresh at the call site via the existing EmitCapturingArrowDisplayInstance (exposed through IEmitterContext.TryEmitCapturingArrowDisplayInstance), exactly as the reflective path does — so per-iteration fresh-binding semantics are preserved (verified: a loop pushing arr.map(x => x + i)[0] yields 10,11,12, not a shared i). ArrowBoxedAdapterEmitter.GetOrEmit gained an `instance` flag selecting the carrier (static $Program vs the display class), arg base (arg0 = `this` for instance), and call opcode (call vs callvirt); the adapter name {method.Name}$box{arity} stays collision-free ("Invoke$box1" is unique within the per-arrow display class, since two arrows sharing a class would already collide on "Invoke"). TryBindTypedArrowAdapter now branches on DisplayClasses: capturing -> instance adapter + display instance; non-capturing -> the L1 static path. Capturing chained map().filter().reduce() now emits instance adapters with zero reflective ldtoken/$TSFunction (IL-verified), ~1.1 ms/call warm (similar to the non-capturing ~2.4x over main's reflective path). 5 new dual-mode tests (capturing map/reduce/chained + the per-iteration fresh-capture guard). Full dotnet test green except known flaky (all pass in isolation; failing set disjoint across runs) and the stale Test262 baselines (regressions are the same pre-existing Array/isArray drift, present in interpreter mode too — no closure/arrow test regressed). --verify clean. --- Compilation/ArrowBoxedAdapterEmitter.cs | 47 ++++++++------ Compilation/Emitters/ArrayEmitter.cs | 52 +++++++++------- Compilation/Emitters/IEmitterContext.cs | 9 +++ Compilation/ExpressionEmitterBase.cs | 9 +++ Compilation/ILEmitter.Calls.Closures.cs | 15 +++++ .../ArrayHofAnnotatedCallbackTests.cs | 61 ++++++++++++++++++- 6 files changed, 149 insertions(+), 44 deletions(-) diff --git a/Compilation/ArrowBoxedAdapterEmitter.cs b/Compilation/ArrowBoxedAdapterEmitter.cs index 78e33e36..a09cf8a4 100644 --- a/Compilation/ArrowBoxedAdapterEmitter.cs +++ b/Compilation/ArrowBoxedAdapterEmitter.cs @@ -50,45 +50,54 @@ internal sealed class ArrowBoxedAdapterEmitter private readonly Dictionary<(MethodBuilder, int), MethodBuilder> _cache = []; /// - /// Returns the boxed adapter for bound to a - /// delegate of object parameters, emitting it on - /// (the arrow's declaring $Program type) - /// on first request. + /// Returns the boxed adapter for bound to a delegate of + /// object parameters, emitting it on + /// on first request. selects the carrier shape: + /// + /// false (#861 L1): a STATIC adapter on $Program calling the non-capturing + /// static arrow (<>Arrow_N). + /// true (#861 L3): an INSTANCE adapter on the capturing arrow's display class, calling + /// this.Invoke(...); the caller binds it to (displayInstance, ldftn adapter). + /// /// - public MethodBuilder GetOrEmit(TypeBuilder programType, MethodBuilder typedArrow, int funcArity) + public MethodBuilder GetOrEmit(TypeBuilder carrierType, MethodBuilder arrowMethod, int funcArity, bool instance) { - var key = (typedArrow, funcArity); + var key = (arrowMethod, funcArity); if (_cache.TryGetValue(key, out var existing)) return existing; var objectType = typeof(object); var adapterParams = new Type[funcArity]; for (int i = 0; i < funcArity; i++) adapterParams[i] = objectType; - // Assembly-visible static, matching the arrow methods on $Program it calls - // into. Name keyed off the arrow's unique method name (e.g. <>Arrow_5) so - // two contexts emitting adapters onto the same $Program never collide. - var adapter = programType.DefineMethod( - $"{typedArrow.Name}$box{funcArity}", - MethodAttributes.Assembly | MethodAttributes.Static, + // Name keyed off the arrow method's name ($Program-unique <>Arrow_N for static; "Invoke" + // for instance, where the per-arrow display class scopes it). Assembly-visible. + var attrs = MethodAttributes.Assembly | (instance ? 0 : MethodAttributes.Static); + var adapter = carrierType.DefineMethod( + $"{arrowMethod.Name}$box{funcArity}", + attrs, objectType, adapterParams); var il = adapter.GetILGenerator(); - var arrowParams = typedArrow.GetParameters(); + var arrowParams = arrowMethod.GetParameters(); + // For an instance adapter arg0 is `this` (pushed first as the Invoke receiver); the delegate + // args then start at arg1. A static adapter's delegate args start at arg0. + int argBase = instance ? 1 : 0; + if (instance) + il.Emit(OpCodes.Ldarg_0); - // Load only as many args as the arrow actually declares, coercing each - // boxed object into its typed parameter slot. A 0-/1-param arrow under a - // 2-arg delegate (or 1-arg delegate) simply ignores the surplus args. + // Load only as many args as the arrow actually declares, coercing each boxed object into its + // typed parameter slot. A 0-/1-param arrow under a wider delegate ignores the surplus args. for (int i = 0; i < arrowParams.Length; i++) { - EmitLdarg(il, i); + EmitLdarg(il, argBase + i); DelegateAdapterEmitter.EmitUnboxForReturn(il, arrowParams[i].ParameterType); } - il.Emit(OpCodes.Call, typedArrow); + il.Emit(instance ? OpCodes.Callvirt : OpCodes.Call, arrowMethod); // Rebox the typed result back to object for the Func contract. - DelegateAdapterEmitter.EmitBoxForTS(il, typedArrow.ReturnType); + DelegateAdapterEmitter.EmitBoxForTS(il, arrowMethod.ReturnType); il.Emit(OpCodes.Ret); _cache[key] = adapter; diff --git a/Compilation/Emitters/ArrayEmitter.cs b/Compilation/Emitters/ArrayEmitter.cs index 54d70cf3..b20cc94d 100644 --- a/Compilation/Emitters/ArrayEmitter.cs +++ b/Compilation/Emitters/ArrayEmitter.cs @@ -897,37 +897,45 @@ private static bool TryEmitDirectDelegateCall(IEmitterContext emitter, List) : typeof(Func); var funcCtor = funcType.GetConstructor([typeof(object), typeof(IntPtr)])!; - // Non-capturing static adapter: target = null, ldftn the adapter. + if (ctx.DisplayClasses.TryGetValue(af, out var displayClass)) + { + // #861 L3: capturing arrow — arrowMethod is the instance Invoke on the display class. + // Emit an INSTANCE adapter, build the display instance (capturing live locals), then bind + // (displayInstance, ldftn adapter). The display instance is built fresh at the call site, + // exactly as the reflective $TSFunction path does. + var adapter = ctx.ArrowBoxedAdapters.GetOrEmit(displayClass, arrowMethod, funcArity, instance: true); + if (!emitter.TryEmitCapturingArrowDisplayInstance(af)) + return false; // Stack: [displayInstance] + ctx.IL.Emit(OpCodes.Ldftn, adapter); + ctx.IL.Emit(OpCodes.Newobj, funcCtor); + return true; + } + + // #861 L1: non-capturing arrow — arrowMethod is a static method on its declaring $Program type. + // Using DeclaringType (rather than ctx.ProgramType, set only on the top-level context) lets the + // adapter fire inside function/method bodies too. Bind (null, ldftn adapter). + if (arrowMethod.DeclaringType is not TypeBuilder programType) return false; + var staticAdapter = ctx.ArrowBoxedAdapters.GetOrEmit(programType, arrowMethod, funcArity, instance: false); ctx.IL.Emit(OpCodes.Ldnull); - ctx.IL.Emit(OpCodes.Ldftn, adapter); + ctx.IL.Emit(OpCodes.Ldftn, staticAdapter); ctx.IL.Emit(OpCodes.Newobj, funcCtor); return true; } diff --git a/Compilation/Emitters/IEmitterContext.cs b/Compilation/Emitters/IEmitterContext.cs index 7779ca0f..72575a63 100644 --- a/Compilation/Emitters/IEmitterContext.cs +++ b/Compilation/Emitters/IEmitterContext.cs @@ -124,4 +124,13 @@ public interface IEmitterContext /// path when this returns false. /// bool TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type delegateType); + + /// + /// Builds the display-class instance for a capturing arrow (new DisplayClass() + /// + populate captured fields), leaving it on the stack. Used by the array-HOF boxed- + /// adapter fast path (#861 L3) to bind an instance adapter to (displayInstance, ldftn + /// instanceAdapter). Returns false (without emitting) for unsupported emitters or when + /// the arrow has no registered display class. + /// + bool TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af); } diff --git a/Compilation/ExpressionEmitterBase.cs b/Compilation/ExpressionEmitterBase.cs index a8daa730..f1c8d4f0 100644 --- a/Compilation/ExpressionEmitterBase.cs +++ b/Compilation/ExpressionEmitterBase.cs @@ -58,6 +58,15 @@ bool IEmitterContext.TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type delegate /// protected virtual bool TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type delegateType) => false; + bool IEmitterContext.TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af) + => TryEmitCapturingArrowDisplayInstance(af); + + /// + /// Default: only can build a capturing arrow's display + /// instance (the display machinery lives there). Other contexts decline (#861 L3). + /// + protected virtual bool TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af) => false; + #endregion protected readonly StateMachineEmitHelpers _helpers; diff --git a/Compilation/ILEmitter.Calls.Closures.cs b/Compilation/ILEmitter.Calls.Closures.cs index 0755733b..e0dcf76f 100644 --- a/Compilation/ILEmitter.Calls.Closures.cs +++ b/Compilation/ILEmitter.Calls.Closures.cs @@ -52,6 +52,21 @@ protected override bool TryEmitArrowAsDelegate(Expr.ArrowFunction af, Type deleg return true; } + /// + /// #861 L3: builds a capturing arrow's display-class instance on the stack so the array-HOF + /// boxed-adapter path can bind an instance adapter to it. Returns false if the arrow has no + /// registered display class / constructor. + /// + protected override bool TryEmitCapturingArrowDisplayInstance(Expr.ArrowFunction af) + { + if (!_ctx.DisplayClasses.TryGetValue(af, out var displayClass)) + return false; + if (!EmitCapturingArrowDisplayInstance(af, displayClass)) + return false; + SetStackUnknown(); + return true; + } + protected override void EmitArrowFunction(Expr.ArrowFunction af) { // Check if this is an async arrow function with a state machine diff --git a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs index 4ba81951..a8517ea8 100644 --- a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs +++ b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs @@ -162,12 +162,14 @@ function f(): string { Assert.Equal("10,21,32\n", TestHarness.Run(source, mode)); } + // ── #861 L3: capturing annotated arrows (instance adapter on the display class) ── + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] - public void CapturingAnnotatedArrow_FallsBack(ExecutionMode mode) + public void CapturingAnnotatedArrow_Map(ExecutionMode mode) { - // Capturing arrow → adapter path is skipped (needs an instance receiver); - // reflective fallback must still produce the captured value correctly. + // Capturing arrow → instance adapter on the display class (L3); the captured value must + // marshal correctly through the adapter. var source = """ function f(): string { const a: number[] = [1, 2, 3]; @@ -179,6 +181,59 @@ function f(): string { Assert.Equal("11,12,13\n", TestHarness.Run(source, mode)); } + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_Reduce(ExecutionMode mode) + { + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + const base: number = 100; + return a.reduce((acc: number, x: number): number => acc + x + base, 0); + } + console.log(f()); + """; + Assert.Equal("515\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_PerIterationFreshCapture(ExecutionMode mode) + { + // The display instance must be built FRESH at each call so each closure captures the + // current loop variable, not a shared one. + var source = """ + function f(): string { + const fns: number[] = []; + for (let i: number = 0; i < 3; i++) { + const arr: number[] = [10, 20]; + fns.push(arr.map((x: number): number => x + i)[0]); + } + return fns.join(","); + } + console.log(f()); + """; + Assert.Equal("10,11,12\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingAnnotatedArrow_Chained(ExecutionMode mode) + { + // Capturing arrows across a chained map().filter().reduce() (L1 capturing adapter + L2 round-trip). + var source = """ + function f(): number { + const a: number[] = [1, 2, 3, 4, 5]; + const m: number = 3; + return a.map((x: number): number => x * m) + .filter((x: number): boolean => x > m) + .reduce((s: number, x: number): number => s + x, 0); + } + console.log(f()); + """; + Assert.Equal("42\n", TestHarness.Run(source, mode)); + } + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void UntypedCallback_StillWorks(ExecutionMode mode) From e2bf93eae685d5a59e6c6eeb1ee65a3c121db476 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 20 Jun 2026 17:30:09 -0700 Subject: [PATCH 4/4] Perf #861 (L4): bool-returning adapter for typed predicates (skip box + IsTruthy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers 1/3 routed every typed annotated callback through an object-returning boxed adapter bound to Func + the object-callback helper, which for a boolean predicate (filter/find/findIndex/some/every) boxes the bool result and then calls IsTruthy on it per element. The predicate helpers already have Func *DirectBool variants (used by the untyped path for bool(object) arrows). L4 routes a typed bool-returning predicate to them: when the arrow returns bool and a boolHelper exists, the adapter is emitted to return the unboxed bool directly (no rebox) and the call site uses the *DirectBool helper — dropping the per-element box + IsTruthy. ArrowBoxedAdapterEmitter.GetOrEmit gained a `boolReturn` flag (adapter return type bool vs object, skip the trailing box, distinct cache key + name marker $bbox); TryBindTypedArrowAdapter binds Func; TryEmitDirectDelegateCall computes wantBool = boolHelper != null && arrow returns bool and selects the variant. Composes with L3: a capturing bool predicate emits a bool-returning INSTANCE adapter bound to ArrayFilterDirectBool. filter/some/every/find/findIndex with typed predicates now emit $bbox adapters + *DirectBool helpers with zero object-variant IsTruthy (IL-verified). +4 dual-mode tests (capturing bool predicate, some/every short-circuit). Full dotnet test green except the stale Test262 baselines (same pre-existing Array/isArray drift; no predicate/filter/some/every/find test regressed). --verify clean. --- Compilation/ArrowBoxedAdapterEmitter.cs | 23 ++++++++----- Compilation/Emitters/ArrayEmitter.cs | 33 +++++++++++------- .../ArrayHofAnnotatedCallbackTests.cs | 34 +++++++++++++++++++ 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/Compilation/ArrowBoxedAdapterEmitter.cs b/Compilation/ArrowBoxedAdapterEmitter.cs index a09cf8a4..005297da 100644 --- a/Compilation/ArrowBoxedAdapterEmitter.cs +++ b/Compilation/ArrowBoxedAdapterEmitter.cs @@ -47,7 +47,7 @@ internal sealed class ArrowBoxedAdapterEmitter // one containing its call site), so this per-context cache never double-defines; // the adapter NAME is derived from the arrow's globally-unique method name so it // stays collision-free across contexts that share the same $Program type. - private readonly Dictionary<(MethodBuilder, int), MethodBuilder> _cache = []; + private readonly Dictionary<(MethodBuilder, int, bool), MethodBuilder> _cache = []; /// /// Returns the boxed adapter for bound to a delegate of @@ -59,23 +59,28 @@ internal sealed class ArrowBoxedAdapterEmitter /// true (#861 L3): an INSTANCE adapter on the capturing arrow's display class, calling /// this.Invoke(...); the caller binds it to (displayInstance, ldftn adapter). /// + /// (#861 L4): when the arrow returns bool and a + /// Func<object,bool> predicate helper is used, the adapter returns the unboxed + /// bool directly (no rebox) so the *DirectBool helper skips the box + IsTruthy. /// - public MethodBuilder GetOrEmit(TypeBuilder carrierType, MethodBuilder arrowMethod, int funcArity, bool instance) + public MethodBuilder GetOrEmit(TypeBuilder carrierType, MethodBuilder arrowMethod, int funcArity, bool instance, bool boolReturn) { - var key = (arrowMethod, funcArity); + var key = (arrowMethod, funcArity, boolReturn); if (_cache.TryGetValue(key, out var existing)) return existing; var objectType = typeof(object); var adapterParams = new Type[funcArity]; for (int i = 0; i < funcArity; i++) adapterParams[i] = objectType; + var adapterReturnType = boolReturn ? typeof(bool) : objectType; // Name keyed off the arrow method's name ($Program-unique <>Arrow_N for static; "Invoke" - // for instance, where the per-arrow display class scopes it). Assembly-visible. + // for instance, where the per-arrow display class scopes it) plus the return-shape marker. + // Assembly-visible. var attrs = MethodAttributes.Assembly | (instance ? 0 : MethodAttributes.Static); var adapter = carrierType.DefineMethod( - $"{arrowMethod.Name}$box{funcArity}", + $"{arrowMethod.Name}${(boolReturn ? "bbox" : "box")}{funcArity}", attrs, - objectType, + adapterReturnType, adapterParams); var il = adapter.GetILGenerator(); @@ -96,8 +101,10 @@ public MethodBuilder GetOrEmit(TypeBuilder carrierType, MethodBuilder arrowMetho il.Emit(instance ? OpCodes.Callvirt : OpCodes.Call, arrowMethod); - // Rebox the typed result back to object for the Func contract. - DelegateAdapterEmitter.EmitBoxForTS(il, arrowMethod.ReturnType); + // bool-return: the arrow already returns bool (caller guarantees) — leave it unboxed for the + // Func contract. Otherwise rebox the typed result to object for Func. + if (!boolReturn) + DelegateAdapterEmitter.EmitBoxForTS(il, arrowMethod.ReturnType); il.Emit(OpCodes.Ret); _cache[key] = adapter; diff --git a/Compilation/Emitters/ArrayEmitter.cs b/Compilation/Emitters/ArrayEmitter.cs index b20cc94d..0cc13157 100644 --- a/Compilation/Emitters/ArrayEmitter.cs +++ b/Compilation/Emitters/ArrayEmitter.cs @@ -877,13 +877,17 @@ private static bool TryEmitDirectDelegateCall(IEmitterContext emitter, List directly. Bind a per-arrow boxed adapter that - // marshals object↔typed around the arrow call, then drive the existing - // object-callback helper. The boxed bool predicate result is handled by - // the helper's own IsTruthy (so the boolHelper/Direct-bool variant is a - // later refinement, #861 L4). - if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 1)) + // marshals object↔typed around the arrow call, then drive the matching + // Array*Direct helper. + // + // #861 L4: when the predicate arrow returns `bool` and a Func + // helper variant exists (filter/find/findIndex/some/every), emit a + // bool-returning adapter and call that variant — skipping the per-element + // result box + IsTruthy the object helper would otherwise do. + bool wantBool = boolHelper != null && staticMethod.ReturnType == ctx.Types.Boolean; + if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 1, boolReturn: wantBool)) return false; - ctx.IL.Emit(OpCodes.Call, helperMethod); + ctx.IL.Emit(OpCodes.Call, wantBool ? boolHelper! : helperMethod); return true; } @@ -898,7 +902,8 @@ private static bool TryBindTypedArrowAdapter( IEmitterContext emitter, Expr.ArrowFunction af, System.Reflection.Emit.MethodBuilder arrowMethod, - int funcArity) + int funcArity, + bool boolReturn) { var ctx = emitter.Context; @@ -910,9 +915,11 @@ private static bool TryBindTypedArrowAdapter( foreach (var p in arrowMethod.GetParameters()) if (!IsAdapterMarshallable(ctx, p.ParameterType)) return false; - Type funcType = funcArity == 2 - ? typeof(Func) - : typeof(Func); + // boolReturn (#861 L4) binds Func for a bool-returning predicate so the + // *DirectBool helper consumes an unboxed bool. Only the 1-arg predicate helpers pass it. + Type funcType = boolReturn + ? typeof(Func) + : (funcArity == 2 ? typeof(Func) : typeof(Func)); var funcCtor = funcType.GetConstructor([typeof(object), typeof(IntPtr)])!; if (ctx.DisplayClasses.TryGetValue(af, out var displayClass)) @@ -921,7 +928,7 @@ private static bool TryBindTypedArrowAdapter( // Emit an INSTANCE adapter, build the display instance (capturing live locals), then bind // (displayInstance, ldftn adapter). The display instance is built fresh at the call site, // exactly as the reflective $TSFunction path does. - var adapter = ctx.ArrowBoxedAdapters.GetOrEmit(displayClass, arrowMethod, funcArity, instance: true); + var adapter = ctx.ArrowBoxedAdapters.GetOrEmit(displayClass, arrowMethod, funcArity, instance: true, boolReturn: boolReturn); if (!emitter.TryEmitCapturingArrowDisplayInstance(af)) return false; // Stack: [displayInstance] ctx.IL.Emit(OpCodes.Ldftn, adapter); @@ -933,7 +940,7 @@ private static bool TryBindTypedArrowAdapter( // Using DeclaringType (rather than ctx.ProgramType, set only on the top-level context) lets the // adapter fire inside function/method bodies too. Bind (null, ldftn adapter). if (arrowMethod.DeclaringType is not TypeBuilder programType) return false; - var staticAdapter = ctx.ArrowBoxedAdapters.GetOrEmit(programType, arrowMethod, funcArity, instance: false); + var staticAdapter = ctx.ArrowBoxedAdapters.GetOrEmit(programType, arrowMethod, funcArity, instance: false, boolReturn: boolReturn); ctx.IL.Emit(OpCodes.Ldnull); ctx.IL.Emit(OpCodes.Ldftn, staticAdapter); ctx.IL.Emit(OpCodes.Newobj, funcCtor); @@ -987,7 +994,7 @@ private static bool TryEmitReduceDirectCall(IEmitterContext emitter, List { // Annotated reducer (e.g. double(double,double)): bridge via a boxed // adapter object(object,object). - if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 2)) + if (!TryBindTypedArrowAdapter(emitter, af, staticMethod, funcArity: 2, boolReturn: false)) return false; } // Push initial value (boxed object). diff --git a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs index a8517ea8..59afa1f9 100644 --- a/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs +++ b/SharpTS.Tests/SharedTests/ArrayHofAnnotatedCallbackTests.cs @@ -216,6 +216,40 @@ function f(): string { Assert.Equal("10,11,12\n", TestHarness.Run(source, mode)); } + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void CapturingBooleanPredicate_Filter(ExecutionMode mode) + { + // L3 (capturing instance adapter) + L4 (bool-returning adapter → *DirectBool): a captured + // threshold in a typed boolean predicate must still filter correctly. + var source = """ + function f(): string { + const a: number[] = [1, 2, 3, 4, 5, 6]; + const t: number = 3; + return a.filter((x: number): boolean => x > t).join(","); + } + console.log(f()); + """; + Assert.Equal("4,5,6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void BooleanPredicate_SomeEvery_ShortCircuit(ExecutionMode mode) + { + // L4 bool adapter under the short-circuiting some/every must agree with the reflective path. + var source = """ + function f(): string { + const a: number[] = [2, 4, 6, 7, 8]; + const someOdd = a.some((x: number): boolean => x % 2 === 1); + const allEven = a.every((x: number): boolean => x % 2 === 0); + return someOdd + "," + allEven; + } + console.log(f()); + """; + Assert.Equal("true,false\n", TestHarness.Run(source, mode)); + } + [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] public void CapturingAnnotatedArrow_Chained(ExecutionMode mode)