From 0f2ba524c5a62e29fa8aa4a60e1acb4679682b2a Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sat, 20 Jun 2026 13:41:35 -0700 Subject: [PATCH] Perf #862: promote non-escaping object literals to generated shape structs A compiled object literal compiled to a Dictionary: each field write a string-keyed set_Item with the value boxed, each read an isinst Dictionary -> TryGetValue -> ConvertToNumber. On the objects benchmark ({ x:i, y:i+1 }) that is ~14x slower than Node despite the shape and field types being statically known. A provably non-escaping const/let object literal with a fixed primitive shape is now promoted to a generated value-type $Shape_N struct with typed fields (number->double, boolean->bool, string->string). o.x reads/writes lower to direct ldfld/stfld -- no Dictionary, no string hash, no boxing -- and a non-escaping struct local is register-promoted by the JIT, so the hot loop becomes pure unboxed double arithmetic. objectWork(20M): ~747ms -> ~25ms (~31x), identical output; the loop emits zero Dictionary/box/GetProperty and IL-verifies. Third instance of the #857/#858 conservative non-escaping-local promotion pattern, and fully additive: any escape (passed/returned/spread/===/captured/ o[expr]/compound-assign/enumerated/non-primitive or undefined-admitting field) disqualifies the local and falls back to the existing Dictionary path, so the object-semantics surface (descriptors, freeze, Object.keys, spread, delete) is untouched. - ObjectLocalPromotionAnalyzer: whole-program escape analysis (mirrors ArrayLocalPromotionAnalyzer / NonEscapingArrowLocalAnalyzer). - TypeMap carries the pure-data ObjectShapeInfo; ObjectShapeRegistry holds the generated TypeBuilders, threaded through the CompilationContext construction sites like DisplayClasses / DirectCallArrowBindings. - DefineObjectShapeTypes emits one $Shape_N per distinct shape (BCL-only fields, so standalone output is preserved); declaration + property get/set fast paths in ILEmitter. The object get/set fast paths precede the TypeInfo.Record Dictionary path (a promoted local is Record-typed but its slot is a struct). Compound member-assign (o.x += v) and async/generator-body locals are deferred (they fall back). +17 dual-mode tests; Object/Record/Property/Freeze/Spread/ ForIn, Compiler/Closure/Loop, Generator/Async, Module/Import/Standalone green. --- Compilation/CompilationContext.Closures.cs | 7 + Compilation/CompilationContext.cs | 25 ++ Compilation/ILCompiler.ArrowFunctions.cs | 1 + Compilation/ILCompiler.Async.cs | 3 + Compilation/ILCompiler.AsyncGenerators.cs | 2 + Compilation/ILCompiler.Classes.Accessors.cs | 1 + .../ILCompiler.Classes.ClassExpressions.cs | 1 + .../ILCompiler.Classes.Constructors.cs | 1 + Compilation/ILCompiler.Classes.Methods.cs | 2 + Compilation/ILCompiler.Classes.Static.cs | 3 + Compilation/ILCompiler.Functions.cs | 3 + Compilation/ILCompiler.Generators.cs | 2 + Compilation/ILCompiler.InnerFunctions.cs | 1 + Compilation/ILCompiler.Modules.cs | 1 + Compilation/ILCompiler.State.cs | 5 + Compilation/ILCompiler.cs | 54 +++ Compilation/ILEmitter.Properties.cs | 56 ++++ Compilation/ILEmitter.Statements.cs | 28 ++ Compilation/ObjectLocalPromotionAnalyzer.cs | 182 ++++++++++ Compilation/ObjectShapeRegistry.cs | 41 +++ .../SharedTests/ObjectLocalPromotionTests.cs | 312 ++++++++++++++++++ TypeSystem/ObjectShapeInfo.cs | 28 ++ TypeSystem/TypeMap.cs | 28 ++ 23 files changed, 787 insertions(+) create mode 100644 Compilation/ObjectLocalPromotionAnalyzer.cs create mode 100644 Compilation/ObjectShapeRegistry.cs create mode 100644 SharpTS.Tests/SharedTests/ObjectLocalPromotionTests.cs create mode 100644 TypeSystem/ObjectShapeInfo.cs diff --git a/Compilation/CompilationContext.Closures.cs b/Compilation/CompilationContext.Closures.cs index b271a65b..b99b8631 100644 --- a/Compilation/CompilationContext.Closures.cs +++ b/Compilation/CompilationContext.Closures.cs @@ -27,6 +27,13 @@ public partial class CompilationContext // `callvirt Invoke` instead of the per-call $TSFunction wrapper + reflective InvokeMethodValue. public Dictionary DirectCallArrowBindings { get; set; } = []; + // Generated value-type "shape" structs for promoted object-literal locals (#862). Shared + // program-wide (populated by DefineObjectShapeTypes after analysis). EmitVarDeclaration resolves the + // struct by canonical shape key for a promoted local; the property get/set fast paths recognise a + // promoted local from its slot's CLR type via ByClrType. See TryGetObjectShapeType / + // TryGetPromotedObjectLocal. + public ObjectShapeRegistry? ObjectShapes { get; set; } + // ============================================ // Self-referential capture write-back (issue #421) // ============================================ diff --git a/Compilation/CompilationContext.cs b/Compilation/CompilationContext.cs index b217f33f..4e2ce55a 100644 --- a/Compilation/CompilationContext.cs +++ b/Compilation/CompilationContext.cs @@ -270,6 +270,31 @@ public IReadOnlyList TakePendingLoopLabels() return null; } + /// + /// Resolves the generated shape struct for a promoted object-literal local by its canonical shape + /// key (#862), or null if shapes are not threaded into this context / the key is unknown. Used at the + /// declaration site to pick the struct type to declare the local with. + /// + public ObjectShapeTypeInfo? TryGetObjectShapeType(string canonicalKey) => + ObjectShapes?.ByKey.GetValueOrDefault(canonicalKey); + + /// + /// If currently binds to a promoted object-literal local (a slot + /// whose CLR type is one of the generated $Shape_N structs, #862), returns its + /// and shape info; otherwise null. The slot's CLR type is the single + /// source of truth, so this is automatically scope-correct under shadowing and never misfires for a + /// captured/object local — no other code path declares a user local with a shape-struct slot. + /// + public (LocalBuilder Local, ObjectShapeTypeInfo Shape)? TryGetPromotedObjectLocal(string variableName) + { + if (ObjectShapes == null) return null; + if (!Locals.TryGetLocal(variableName, out var local)) return null; + var slotType = Locals.GetLocalType(variableName); + if (slotType != null && ObjectShapes.ByClrType.TryGetValue(slotType, out var shape)) + return (local, shape); + return null; + } + // Exception block tracking for proper return handling public int ExceptionBlockDepth { get; set; } = 0; public LocalBuilder? ReturnValueLocal { get; set; } diff --git a/Compilation/ILCompiler.ArrowFunctions.cs b/Compilation/ILCompiler.ArrowFunctions.cs index 15ebd122..13e9fa41 100644 --- a/Compilation/ILCompiler.ArrowFunctions.cs +++ b/Compilation/ILCompiler.ArrowFunctions.cs @@ -1259,6 +1259,7 @@ private void EmitArrowBody(Expr.ArrowFunction arrow, MethodBuilder method, TypeB ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Async.cs b/Compilation/ILCompiler.Async.cs index 4c9a9fe3..fd7071b1 100644 --- a/Compilation/ILCompiler.Async.cs +++ b/Compilation/ILCompiler.Async.cs @@ -714,6 +714,7 @@ private void EmitAsyncStateMachineBodies() ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -1131,6 +1132,7 @@ private void EmitAsyncMethodBody(MethodBuilder methodBuilder, Stmt.Function meth ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -1364,6 +1366,7 @@ private void EmitTopLevelAsyncArrowBodies() ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.AsyncGenerators.cs b/Compilation/ILCompiler.AsyncGenerators.cs index a484123b..d82dcaaf 100644 --- a/Compilation/ILCompiler.AsyncGenerators.cs +++ b/Compilation/ILCompiler.AsyncGenerators.cs @@ -187,6 +187,7 @@ private void EmitAsyncGeneratorMoveNextAsyncBody(AsyncGeneratorStateMachineBuild ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -291,6 +292,7 @@ private void EmitAsyncGeneratorMethodBody(MethodBuilder methodBuilder, Stmt.Func ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Classes.Accessors.cs b/Compilation/ILCompiler.Classes.Accessors.cs index 8c1b522c..5d525d90 100644 --- a/Compilation/ILCompiler.Classes.Accessors.cs +++ b/Compilation/ILCompiler.Classes.Accessors.cs @@ -176,6 +176,7 @@ private void EmitAccessorBody(TypeBuilder typeBuilder, Stmt.Accessor accessor, M ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Classes.ClassExpressions.cs b/Compilation/ILCompiler.Classes.ClassExpressions.cs index 88493337..27a876b9 100644 --- a/Compilation/ILCompiler.Classes.ClassExpressions.cs +++ b/Compilation/ILCompiler.Classes.ClassExpressions.cs @@ -499,6 +499,7 @@ private CompilationContext CreateClassExpressionContext( ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Classes.Constructors.cs b/Compilation/ILCompiler.Classes.Constructors.cs index 21876a1c..13278df5 100644 --- a/Compilation/ILCompiler.Classes.Constructors.cs +++ b/Compilation/ILCompiler.Classes.Constructors.cs @@ -51,6 +51,7 @@ private void EmitConstructor(TypeBuilder typeBuilder, Stmt.Class classStmt, Fiel ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Classes.Methods.cs b/Compilation/ILCompiler.Classes.Methods.cs index 2dcfca68..a3b50f4c 100644 --- a/Compilation/ILCompiler.Classes.Methods.cs +++ b/Compilation/ILCompiler.Classes.Methods.cs @@ -928,6 +928,7 @@ private void EmitPrivateMethodBody( ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -1220,6 +1221,7 @@ private void EmitMethod(TypeBuilder typeBuilder, Stmt.Function method, FieldInfo ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Classes.Static.cs b/Compilation/ILCompiler.Classes.Static.cs index c16fe517..e617652b 100644 --- a/Compilation/ILCompiler.Classes.Static.cs +++ b/Compilation/ILCompiler.Classes.Static.cs @@ -69,6 +69,7 @@ private void EmitStaticConstructor(TypeBuilder typeBuilder, Stmt.Class classStmt ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -382,6 +383,7 @@ private void EmitStaticMethodBody(string className, Stmt.Function method) ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -625,6 +627,7 @@ private void EmitStaticAsyncMethodBody(string className, Stmt.Function method) ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Functions.cs b/Compilation/ILCompiler.Functions.cs index 8cb6b0f9..da0fe7b4 100644 --- a/Compilation/ILCompiler.Functions.cs +++ b/Compilation/ILCompiler.Functions.cs @@ -351,6 +351,7 @@ private void EmitFunctionBody(Stmt.Function funcStmt) ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -1007,6 +1008,7 @@ private void EmitDefaultEntryPoint(List statements) ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -1167,6 +1169,7 @@ private void EmitExeEntryPointWithUserMain(List statements, Stmt.Function ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Generators.cs b/Compilation/ILCompiler.Generators.cs index 460aeef9..fca2a5ff 100644 --- a/Compilation/ILCompiler.Generators.cs +++ b/Compilation/ILCompiler.Generators.cs @@ -403,6 +403,7 @@ private void EmitGeneratorMoveNextBody(GeneratorStateMachineBuilder smBuilder, S ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, @@ -513,6 +514,7 @@ private void EmitGeneratorMethodBody(MethodBuilder methodBuilder, Stmt.Function ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.InnerFunctions.cs b/Compilation/ILCompiler.InnerFunctions.cs index 81a7a831..7e7da0b9 100644 --- a/Compilation/ILCompiler.InnerFunctions.cs +++ b/Compilation/ILCompiler.InnerFunctions.cs @@ -449,6 +449,7 @@ private void EmitInnerFunctionBodies() ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.Modules.cs b/Compilation/ILCompiler.Modules.cs index dddd15c6..ac858406 100644 --- a/Compilation/ILCompiler.Modules.cs +++ b/Compilation/ILCompiler.Modules.cs @@ -705,6 +705,7 @@ private CompilationContext CreateCompilationContext(ILGenerator il) ArrowMethods = _closures.ArrowMethods, ConstArrowBindings = _closures.ConstArrowBindings, DirectCallArrowBindings = _closures.DirectCallArrowBindings, + ObjectShapes = _closures.ObjectShapes, DisplayClasses = _closures.DisplayClasses, DisplayClassFields = _closures.DisplayClassFields, DisplayClassConstructors = _closures.DisplayClassConstructors, diff --git a/Compilation/ILCompiler.State.cs b/Compilation/ILCompiler.State.cs index 5406cb70..8cbede81 100644 --- a/Compilation/ILCompiler.State.cs +++ b/Compilation/ILCompiler.State.cs @@ -138,6 +138,11 @@ private sealed class ClosureCompilationState // NonEscapingArrowLocalAnalyzer; the fast path only fires for capturing arrows whose in-scope // local slot type matches the display class (so a same-named binding elsewhere never hits it). public Dictionary DirectCallArrowBindings { get; } = []; + + // Generated value-type "shape" structs for promoted object-literal locals (#862). Populated + // after ObjectLocalPromotionAnalyzer by DefineObjectShapeTypes; the declaration site keys in by + // canonical shape key, and the property get/set fast paths key in by the local slot's CLR type. + public ObjectShapeRegistry ObjectShapes { get; } = new(); public Dictionary DisplayClasses { get; } = new(ReferenceEqualityComparer.Instance); public Dictionary> DisplayClassFields { get; } = new(ReferenceEqualityComparer.Instance); public Dictionary DisplayClassConstructors { get; } = new(ReferenceEqualityComparer.Instance); diff --git a/Compilation/ILCompiler.cs b/Compilation/ILCompiler.cs index 12264357..2d4bf9f0 100644 --- a/Compilation/ILCompiler.cs +++ b/Compilation/ILCompiler.cs @@ -426,7 +426,9 @@ public void Compile(List statements, TypeMap typeMap, DeadCodeInfo? deadCo Phase2_AnalyzeClosures(statements); ArrayLocalPromotionAnalyzer.Analyze(statements, _typeMap, _closures.Analyzer); NonEscapingArrowLocalAnalyzer.Analyze(statements, _closures.DirectCallArrowBindings, _closures.Analyzer); + ObjectLocalPromotionAnalyzer.Analyze(statements, _typeMap, _closures.Analyzer); Phase3_CreateProgramType(); + DefineObjectShapeTypes(); PreScanBuiltInModuleImports(statements); Phase4_DefineDeclarations(statements); Phase5_CollectArrowFunctions(statements); @@ -705,6 +707,48 @@ private void Phase8_EmitEntryPoint(List statements) EmitEntryPoint(statements); } + /// + /// Defines one generated value-type $Shape_N struct per distinct promotable object-literal + /// shape (#862), de-duplicated by canonical key. Each field becomes a typed public field + /// (numberdouble, booleanbool, stringstring). The structs + /// reference only BCL types, so compiled output stays standalone (no SharpTS.dll dependency). They are + /// finalized (CreateType) at the top of the finalize phase, before any type that uses them. + /// + private void DefineObjectShapeTypes() + { + if (_typeMap == null) return; + foreach (var shape in _typeMap.PromotableObjectLocalShapes) + { + if (_closures.ObjectShapes.ByKey.ContainsKey(shape.CanonicalKey)) continue; + + var structType = _moduleBuilder.DefineType( + $"$Shape_{_closures.ObjectShapes.Counter++}", + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.SequentialLayout, + _types.ValueType); + + var fieldBuilders = new Dictionary(StringComparer.Ordinal); + foreach (var (name, kind) in shape.Fields) + { + var clr = kind switch + { + TokenType.TYPE_NUMBER => _types.Double, + TokenType.TYPE_BOOLEAN => _types.Boolean, + _ => _types.String, + }; + fieldBuilders[name] = structType.DefineField(name, clr, FieldAttributes.Public); + } + + var info = new ObjectShapeTypeInfo + { + ClrType = structType, + Fields = shape.Fields.Select(f => (f.Name, f.Kind)).ToList(), + FieldBuilders = fieldBuilders, + }; + _closures.ObjectShapes.ByKey[shape.CanonicalKey] = info; + _closures.ObjectShapes.ByClrType[structType] = info; + } + } + /// /// Phase 9: Finalize all types by calling CreateType(). /// @@ -712,6 +756,10 @@ private void Phase9_FinalizeTypes() { _unionGenerator?.FinalizeAllUnionTypes(); + // Finalize generated object-literal shape structs (#862) before any type that uses them. + foreach (var info in _closures.ObjectShapes.ByKey.Values) + ((TypeBuilder)info.ClrType).CreateType(); + // Finalize entry-point display class first (needed by closures) _closures.EntryPointDisplayClass?.CreateType(); @@ -908,7 +956,9 @@ public void CompileModules(List modules, ModuleResolver resolver, Phase2_AnalyzeClosures(allStatements); ArrayLocalPromotionAnalyzer.Analyze(allStatements, _typeMap, _closures.Analyzer); NonEscapingArrowLocalAnalyzer.Analyze(allStatements, _closures.DirectCallArrowBindings, _closures.Analyzer); + ObjectLocalPromotionAnalyzer.Analyze(allStatements, _typeMap, _closures.Analyzer); Phase3_CreateProgramType(); + DefineObjectShapeTypes(); // Scope each module's named-import bindings to that module so local // aliases like __platform don't collide between stdlib modules. foreach (var m in modules) @@ -1167,6 +1217,10 @@ private void ModulePhase11_FinalizeTypes() _unionGenerator?.FinalizeAllUnionTypes(); + // Finalize generated object-literal shape structs (#862) before any type that uses them. + foreach (var info in _closures.ObjectShapes.ByKey.Values) + ((TypeBuilder)info.ClrType).CreateType(); + // Finalize entry-point display class first (needed by closures) _closures.EntryPointDisplayClass?.CreateType(); diff --git a/Compilation/ILEmitter.Properties.cs b/Compilation/ILEmitter.Properties.cs index 8bd1bab5..632336b4 100644 --- a/Compilation/ILEmitter.Properties.cs +++ b/Compilation/ILEmitter.Properties.cs @@ -20,6 +20,21 @@ protected override void EmitGet(Expr.Get g) // CommonJS: `module.exports` reads → ldsfld $exports if (TryEmitCjsGet(g)) return; + // Promoted object-literal shape struct (#862): `o.KEY` reads the typed struct field directly + // (ldloca + ldfld) — no Dictionary lookup, no string hash, no unbox. Keyed off the slot's CLR + // type, so it is scope-correct and never misfires for a non-promoted local. The analyzer + // guarantees KEY is one of the shape's fields. Must precede the TypeInfo.Record fast path below: + // a promoted local is also Record-typed, but its slot is a struct, not a Dictionary. + if (!g.Optional && g.Object is Expr.Variable shapeVarGet + && _ctx.TryGetPromotedObjectLocal(shapeVarGet.Name.Lexeme) is { } poGet + && poGet.Shape.FieldBuilders.TryGetValue(g.Name.Lexeme, out var fbGet)) + { + IL.Emit(OpCodes.Ldloca, poGet.Local); + IL.Emit(OpCodes.Ldfld, fbGet); + SetStackTypeForFieldType(fbGet.FieldType); + return; + } + // Syntactic shortcut: `arguments.length` → load $Arguments._length // directly. The static-type-driven dispatch path emits .NET // List.Count (via a helper that bypasses GetProperty), @@ -486,6 +501,26 @@ private void EmitTypedRecordPropertySet(Expr.Set s) SetStackUnknown(); } + /// + /// Coerces the value currently on the stack to a promoted shape struct field's CLR type (#862). + /// The analyzer guarantees the value's static kind already matches the field, so the underlying + /// Ensure* helper is a no-op in practice — it only fixes an unexpected boxed/widened representation. + /// + private void EnsureForFieldType(Type fieldType) + { + if (fieldType == _ctx.Types.Double) EnsureDouble(); + else if (fieldType == _ctx.Types.Boolean) EnsureBoolean(); + else EnsureString(); + } + + /// Sets the stack-type tracker to match a promoted shape struct field's CLR type (#862). + private void SetStackTypeForFieldType(Type fieldType) + { + if (fieldType == _ctx.Types.Double) SetStackType(StackType.Double); + else if (fieldType == _ctx.Types.Boolean) SetStackType(StackType.Boolean); + else SetStackType(StackType.String); + } + protected override void EmitSet(Expr.Set s) { // CommonJS: `module.exports = X` writes → stsfld $exports @@ -519,6 +554,27 @@ protected override void EmitSet(Expr.Set s) return; } + // Promoted object-literal shape struct (#862): `o.KEY = v` writes the typed struct field + // directly (ldloca + stfld) — no Dictionary, no freeze/seal probe, no boxing. The expression's + // result is the assigned value (typed). Keyed off the slot's CLR type (scope-correct). The + // analyzer guarantees KEY ∈ shape and v's static kind matches the field. Must precede the + // TypeInfo.Record fast path below (a promoted local is Record-typed but slot is a struct). + if (s.Object is Expr.Variable shapeVarSet + && _ctx.TryGetPromotedObjectLocal(shapeVarSet.Name.Lexeme) is { } poSet + && poSet.Shape.FieldBuilders.TryGetValue(s.Name.Lexeme, out var fbSet)) + { + EmitExpression(s.Value); + EnsureForFieldType(fbSet.FieldType); + var valueTemp = IL.DeclareLocal(fbSet.FieldType); + IL.Emit(OpCodes.Stloc, valueTemp); + IL.Emit(OpCodes.Ldloca, poSet.Local); + IL.Emit(OpCodes.Ldloc, valueTemp); + IL.Emit(OpCodes.Stfld, fbSet); + IL.Emit(OpCodes.Ldloc, valueTemp); // expression result: the assigned value + SetStackTypeForFieldType(fbSet.FieldType); + return; + } + // Handle static property assignment via 'this' in static context (static blocks, static methods) if (s.Object is Expr.This && !_ctx.IsInstanceMethod && _ctx.CurrentClassBuilder != null) { diff --git a/Compilation/ILEmitter.Statements.cs b/Compilation/ILEmitter.Statements.cs index d6f08908..f205820c 100644 --- a/Compilation/ILEmitter.Statements.cs +++ b/Compilation/ILEmitter.Statements.cs @@ -185,6 +185,34 @@ protected override void EmitVarDeclaration(Stmt.Var v) // path, which re-emits the arrow as a $TSFunction wrapper into a fresh object local. } + // Non-escaping object-literal local (#862): a provably non-escaping `const o = { x: …, y: … }` + // whose literal has a fixed, statically-known primitive shape is promoted to a generated + // value-type "shape" struct local with typed fields. Field reads/writes (`o.x`) lower to direct + // ldfld/stfld — no Dictionary, no string hash, no boxing — and a non-escaping struct local is + // register-promoted by the JIT. Reached only after the capture branches above (a captured name is + // excluded by the analyzer). Falls through to the generic Dictionary path if shapes aren't + // threaded into this context (e.g. async/generator bodies, which never consult the mark). + if (_ctx.TypeMap != null && v.Initializer is Expr.ObjectLiteral shapeLit + && _ctx.TypeMap.IsPromotableObjectLocal(v.Name, out var objShape) + && _ctx.TryGetObjectShapeType(objShape.CanonicalKey) is { } shapeType) + { + var structLocal = _ctx.Locals.DeclareLocal(v.Name.Lexeme, shapeType.ClrType); + IL.Emit(OpCodes.Ldloca, structLocal); + IL.Emit(OpCodes.Initobj, shapeType.ClrType); + // Evaluate every field initializer in source order (preserving side effects), even a field + // never read later, and store into its typed struct field. + foreach (var prop in shapeLit.Properties) + { + var fieldName = ((Expr.IdentifierKey)prop.Key!).Name.Lexeme; + var fb = shapeType.FieldBuilders[fieldName]; + IL.Emit(OpCodes.Ldloca, structLocal); + EmitExpression(prop.Value); + EnsureForFieldType(fb.FieldType); + IL.Emit(OpCodes.Stfld, fb); + } + return; + } + // Typed-array-local promotion (#857/#860): a provably non-escaping number[]/boolean[] // local with an empty-array-literal initializer gets a concrete List/List // slot, so index get/set, .length, and push/pop lower to direct typed ops with no diff --git a/Compilation/ObjectLocalPromotionAnalyzer.cs b/Compilation/ObjectLocalPromotionAnalyzer.cs new file mode 100644 index 00000000..8e87e401 --- /dev/null +++ b/Compilation/ObjectLocalPromotionAnalyzer.cs @@ -0,0 +1,182 @@ +using SharpTS.Parsing; +using SharpTS.Parsing.Visitors; +using SharpTS.TypeSystem; + +namespace SharpTS.Compilation; + +/// +/// Whole-program analysis that flags const/let object-literal locals which can be promoted +/// from the default Dictionary<string, object> to a generated value-type "shape" struct with +/// typed fields (#862). Direct sibling of and +/// : a name qualifies only if it is provably non-escaping, so +/// the promoted struct (which has no dynamic-object semantics — no descriptors, enumerability, prototype, +/// delete, spread, freeze) is never observed anywhere those would be needed. A candidate o +/// qualifies iff ALL hold: +/// +/// declared const/let with an initializer that is a simple object literal — +/// every property is a plain key: value (no spread, no computed/string-literal key, no +/// method, no getter/setter, no { a = 5 } cover-grammar shorthand-default), keys are +/// unique, and every value's static type is a primitive number/boolean/string +/// (which inherently excludes any/undefined-admitting fields a typed slot would +/// silently coerce); +/// the ONLY uses are constant-key field reads o.KEY and writes o.KEY = v where +/// KEY is one of the literal's own fields (and a write's value is the same primitive kind). +/// Any other appearance of the bare variable — argument pass, return, store to another binding, +/// spread, ===, typeof, o[expr], o.unknownKey, delete, +/// compound/logical member assign, reassignment — disqualifies it; +/// the name is declared exactly once in the whole program (conservative guard against scope +/// ambiguity / shadowing without full scope resolution); +/// the name is not captured by any closure (a captured local is routed to an object +/// display-class field, never a typed struct slot the get/set fast path can key on). +/// +/// +/// The catch-all is : any bare variable occurrence not consumed +/// by the permitted-read/write overrides disqualifies the name. The permitted overrides deliberately do +/// NOT recurse into the receiver variable, so only the safe o.KEY shapes survive. Compound and +/// logical member assignment (o.x += v, o.x ??= v) are intentionally NOT permitted in this +/// first cut — they fall through to the catch-all and disqualify (follow-up). +/// +public static class ObjectLocalPromotionAnalyzer +{ + public static void Analyze(List program, TypeMap? typeMap, ClosureAnalyzer? closures) + { + if (typeMap == null) return; + + var visitor = new Visitor(typeMap); + foreach (var stmt in program) + visitor.Visit(stmt); + + foreach (var (name, candidate) in visitor.Candidates) + { + if (visitor.Disqualified.Contains(name)) continue; + if (visitor.DeclCount.GetValueOrDefault(name) != 1) continue; + if (closures?.IsVariableCaptured(name) == true) continue; + typeMap.MarkPromotableObjectLocal(candidate.NameToken, candidate.Shape); + } + } + + private sealed class Visitor(TypeMap typeMap) : AstVisitorBase + { + private readonly TypeMap _typeMap = typeMap; + + /// name → its candidate declaration (name token, shape, and the field-name set for O(1) membership). + public Dictionary FieldNames)> Candidates { get; } = new(); + + /// How many times each name is declared anywhere (any kind of binding). + public Dictionary DeclCount { get; } = new(); + + /// Names with at least one disqualifying occurrence. + public HashSet Disqualified { get; } = new(); + + protected override void VisitVar(Stmt.Var stmt) => + HandleDeclaration(stmt.Name, stmt.Initializer); + + protected override void VisitConst(Stmt.Const stmt) => + HandleDeclaration(stmt.Name, stmt.Initializer); + + private void HandleDeclaration(Token name, Expr? initializer) + { + var lexeme = name.Lexeme; + DeclCount[lexeme] = DeclCount.GetValueOrDefault(lexeme) + 1; + + if (initializer is Expr.ObjectLiteral lit && !Candidates.ContainsKey(lexeme) + && TryBuildShape(lit, out var shape, out var fieldNames)) + Candidates[lexeme] = (name, shape, fieldNames); + + // Visit the initializer so its sub-uses are accounted for. The literal's own property + // values reference OTHER variables (e.g. `i` in `{ x: i }`), not `o`, so this never + // disqualifies the candidate itself. + if (initializer != null) + Visit(initializer); + } + + protected override void VisitGet(Expr.Get expr) + { + // `o.KEY` read — permitted when receiver is a candidate variable and KEY is one of its + // fields. Do NOT recurse into the receiver variable (which would disqualify via the + // catch-all). A non-optional dot read only. + if (!expr.Optional && expr.Object is Expr.Variable v + && Candidates.TryGetValue(v.Name.Lexeme, out var c) + && c.FieldNames.Contains(expr.Name.Lexeme)) + return; + base.VisitGet(expr); + } + + protected override void VisitSet(Expr.Set expr) + { + // `o.KEY = v` write — permitted; visit the value but not the receiver variable. + if (expr.Object is Expr.Variable v + && Candidates.TryGetValue(v.Name.Lexeme, out var c) + && c.FieldNames.Contains(expr.Name.Lexeme)) + { + Visit(expr.Value); + // The written value must be the SAME primitive kind as the field; otherwise the typed + // slot would coerce it (a number field written with `any`/string diverges). Disqualify. + if (ClassifyKind(_typeMap.Get(expr.Value)) != FieldKind(c.Shape, expr.Name.Lexeme)) + Disqualified.Add(v.Name.Lexeme); + return; + } + base.VisitSet(expr); + } + + protected override void VisitVariable(Expr.Variable expr) + { + // Catch-all: any bare variable occurrence not consumed by a permitted read/write override + // is an escape (returned, passed, spread, compared, dynamically indexed, compound-assigned, + // reassigned, ...). + Disqualified.Add(expr.Name.Lexeme); + } + + /// + /// Builds the shape for a candidate object literal, or returns false if the literal is not a + /// simple fixed-shape primitive record. See the class summary for the rules. + /// + private bool TryBuildShape(Expr.ObjectLiteral lit, out ObjectShapeInfo shape, out HashSet fieldNames) + { + shape = null!; + fieldNames = null!; + if (lit.Properties.Count == 0) return false; + + var fields = new List(lit.Properties.Count); + var names = new HashSet(StringComparer.Ordinal); + foreach (var prop in lit.Properties) + { + if (prop.IsSpread || prop.Kind != Expr.ObjectPropertyKind.Value || prop.IsShorthandDefault) + return false; + if (prop.Key is not Expr.IdentifierKey idk) + return false; // computed / string-literal / numeric keys: not o.KEY-accessible + var fname = idk.Name.Lexeme; + if (!names.Add(fname)) + return false; // duplicate key + if (ClassifyKind(_typeMap.Get(prop.Value)) is not { } kind) + return false; // non-primitive / undefined-admitting field + fields.Add(new ObjectShapeField(fname, kind)); + } + + var key = string.Join(";", fields.Select(f => f.Name + ":" + f.Kind)); + shape = new ObjectShapeInfo(key, fields); + fieldNames = names; + return true; + } + + private static TokenType FieldKind(ObjectShapeInfo shape, string name) + { + foreach (var f in shape.Fields) + if (f.Name == name) return f.Kind; + return TokenType.TYPE_NUMBER; // unreachable: callers pass a known field name + } + + /// + /// Classifies a static type as a promotable primitive kind, or null. Only a bare primitive + /// number/boolean/string qualifies — which inherently excludes + /// any/unknown/undefined and unions (a value the typed slot would coerce). + /// + private static TokenType? ClassifyKind(TypeInfo? type) => type switch + { + TypeInfo.Primitive { Type: TokenType.TYPE_NUMBER } => TokenType.TYPE_NUMBER, + TypeInfo.Primitive { Type: TokenType.TYPE_BOOLEAN } => TokenType.TYPE_BOOLEAN, + TypeInfo.Primitive { Type: TokenType.TYPE_STRING } => TokenType.TYPE_STRING, + _ => null + }; + } +} diff --git a/Compilation/ObjectShapeRegistry.cs b/Compilation/ObjectShapeRegistry.cs new file mode 100644 index 00000000..74ba93ad --- /dev/null +++ b/Compilation/ObjectShapeRegistry.cs @@ -0,0 +1,41 @@ +using System.Reflection.Emit; +using SharpTS.Parsing; + +namespace SharpTS.Compilation; + +/// +/// Program-wide cache of generated value-type "shape" structs for promoted object-literal locals (#862). +/// Mirrors the role of DisplayClasses: defined once after analysis, shared across every emit +/// context, and finalized (CreateType()) at the end of compilation. Keyed two ways — by the +/// shape's (so the declaration site resolves the +/// generated type from the mark) and by the generated CLR +/// (so the property get/set fast paths recognise a promoted local purely from its slot type — the same +/// CLR-type-gated, shadow-safe resolution used by promoted typed-array locals). +/// +public sealed class ObjectShapeRegistry +{ + /// Canonical shape key → generated shape type info. + public Dictionary ByKey { get; } = new(StringComparer.Ordinal); + + /// Generated CLR type (the ) → shape type info, for use-site lookup. + public Dictionary ByClrType { get; } = new(); + + /// Monotonic counter for unique $Shape_N type names. + public int Counter { get; set; } +} + +/// +/// One generated shape struct: its CLR type, the ordered fields (name + primitive kind), and the +/// for each field name (used to emit ldfld/stfld). +/// +public sealed class ObjectShapeTypeInfo +{ + /// The generated value type (a during emit; finalized later). + public required Type ClrType { get; init; } + + /// Ordered fields (name + kind), matching the literal's property order. + public required IReadOnlyList<(string Name, TokenType Kind)> Fields { get; init; } + + /// Field name → its on . + public required Dictionary FieldBuilders { get; init; } +} diff --git a/SharpTS.Tests/SharedTests/ObjectLocalPromotionTests.cs b/SharpTS.Tests/SharedTests/ObjectLocalPromotionTests.cs new file mode 100644 index 00000000..9333b186 --- /dev/null +++ b/SharpTS.Tests/SharedTests/ObjectLocalPromotionTests.cs @@ -0,0 +1,312 @@ +using SharpTS.Tests.Infrastructure; +using Xunit; + +namespace SharpTS.Tests.SharedTests; + +/// +/// Tests for the object-literal "shape struct" promotion optimization (#862): a provably non-escaping +/// const/let object literal with a fixed, statically-known primitive shape is compiled to a +/// generated value-type struct with typed fields (number→double, boolean→bool, string→string), so +/// o.x reads/writes lower to direct ldfld/stfld instead of a +/// Dictionary<string, object> lookup with boxing. +/// +/// These run against BOTH the interpreter and the compiler. The positive cases exercise the promoted +/// fast paths; the escape cases must NOT be promoted (they fall back to the general Dictionary path) and +/// must still produce correct results — i.e. interpreter/compiled parity must hold even when the object +/// is passed, returned, spread, enumerated, dynamically indexed, compared, captured, or compound-assigned. +/// A wrong escape rule, or a miscompiled struct fast path, surfaces here as a compiled-mode mismatch. +/// +public class ObjectLocalPromotionTests +{ + // ── Positive cases: promotable shapes ────────────────────────────────── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_NumberRecord_ReadFieldsInLoop(ExecutionMode mode) + { + // The benchmark shape (objectWork): a fresh per-iteration record read by field. + // sum += o.x + o.y = i + (i+1) = 2i+1, summed over [0,n) → n^2. + var source = """ + function objectWork(n: number): number { + let sum: number = 0; + for (let i: number = 0; i < n; i++) { + const o = { x: i, y: i + 1 }; + sum = sum + o.x + o.y; + } + return sum; + } + console.log(objectWork(100)); + """; + + Assert.Equal("10000\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_FieldWrite_MutatesThenReads(ExecutionMode mode) + { + // `const o` binds the slot but its fields stay mutable. + var source = """ + function f(): number { + const o = { x: 1, y: 2 }; + o.x = 10; + return o.x + o.y; + } + console.log(f()); + """; + + Assert.Equal("12\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_FieldWrite_ReturnsAssignedValue(ExecutionMode mode) + { + // `o.x = v` is an expression whose value is the assigned RHS. + var source = """ + function f(): number { + const o = { x: 0, y: 0 }; + const v: number = (o.x = 42); + return v + o.x; + } + console.log(f()); + """; + + Assert.Equal("84\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_BooleanAndNumberFields(ExecutionMode mode) + { + var source = """ + function f(): number { + const o = { ok: true, n: 5 }; + if (o.ok) { return o.n; } + return 0; + } + console.log(f()); + """; + + Assert.Equal("5\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_StringFields_Concat(ExecutionMode mode) + { + var source = """ + function f(): string { + const o = { first: "a", last: "b" }; + return o.first + o.last; + } + console.log(f()); + """; + + Assert.Equal("ab\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_MixedPrimitiveFields(ExecutionMode mode) + { + // number + string + boolean fields in one shape, with a string-typed result mixing kinds. + var source = """ + function f(): string { + const o = { id: "x", count: 3, active: true }; + let s: string = o.id + o.count; + if (o.active) { s = s + "!"; } + return s; + } + console.log(f()); + """; + + Assert.Equal("x3!\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_MultipleObjects_Independent(ExecutionMode mode) + { + var source = """ + function f(): number { + const a = { x: 1, y: 2 }; + const b = { x: 10, y: 20 }; + a.x = 100; + return a.x + a.y + b.x + b.y; + } + console.log(f()); + """; + + // 100 + 2 + 10 + 20 = 132 + Assert.Equal("132\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Promoted_FieldWrittenFromComputedNumber(ExecutionMode mode) + { + var source = """ + function f(n: number): number { + const o = { a: 0, b: 0 }; + o.a = n * 2; + o.b = o.a + 1; + return o.a + o.b; + } + console.log(f(5)); + """; + + // a = 10, b = 11 → 21 + Assert.Equal("21\n", TestHarness.Run(source, mode)); + } + + // ── Escape cases: must fall back, must stay correct ──────────────────── + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_PassedToFunction(ExecutionMode mode) + { + var source = """ + function sumXY(p: { x: number; y: number }): number { return p.x + p.y; } + function f(): number { + const o = { x: 3, y: 4 }; + return sumXY(o); + } + console.log(f()); + """; + + Assert.Equal("7\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_Returned(ExecutionMode mode) + { + var source = """ + function make(): { x: number; y: number } { + const o = { x: 7, y: 8 }; + return o; + } + const r = make(); + console.log(r.x + r.y); + """; + + Assert.Equal("15\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_Spread(ExecutionMode mode) + { + var source = """ + function f(): number { + const o = { x: 1, y: 2 }; + const p = { ...o, z: 3 }; + return p.x + p.y + p.z; + } + console.log(f()); + """; + + Assert.Equal("6\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_ForInEnumeration(ExecutionMode mode) + { + var source = """ + function f(): number { + const o = { x: 1, y: 2, z: 3 }; + let count: number = 0; + for (const k in o) { count = count + 1; } + return count; + } + console.log(f()); + """; + + Assert.Equal("3\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_StrictEquality(ExecutionMode mode) + { + // Object identity comparison — a promoted struct local has no stable reference, so this must + // fall back. Two distinct literals are never reference-equal. + var source = """ + function f(): number { + const a = { x: 1, y: 2 }; + const b = { x: 1, y: 2 }; + return (a === b) ? 1 : 0; + } + console.log(f()); + """; + + Assert.Equal("0\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_CapturedByClosure(ExecutionMode mode) + { + var source = """ + function f(): number { + const o = { x: 5, y: 6 }; + const get = (): number => o.x; + return get() + o.y; + } + console.log(f()); + """; + + Assert.Equal("11\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_CompoundFieldAssign(ExecutionMode mode) + { + // `o.x += v` is intentionally not promoted in the first cut → falls back, must stay correct. + var source = """ + function f(): number { + const o = { x: 1, y: 2 }; + o.x += 10; + return o.x + o.y; + } + console.log(f()); + """; + + Assert.Equal("13\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_NestedObjectField(ExecutionMode mode) + { + // A non-primitive (nested object) field disqualifies the shape → falls back. + var source = """ + function f(): number { + const o = { a: { b: 1 }, c: 2 }; + return o.a.b + o.c; + } + console.log(f()); + """; + + Assert.Equal("3\n", TestHarness.Run(source, mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Escape_DynamicBracketRead(ExecutionMode mode) + { + // Bracket access (even with a literal key) is a dynamic index → disqualifies → falls back. + var source = """ + function f(): number { + const o = { x: 4, y: 5 }; + return o["x"] + o["y"]; + } + console.log(f()); + """; + + Assert.Equal("9\n", TestHarness.Run(source, mode)); + } +} diff --git a/TypeSystem/ObjectShapeInfo.cs b/TypeSystem/ObjectShapeInfo.cs new file mode 100644 index 00000000..cb97757d --- /dev/null +++ b/TypeSystem/ObjectShapeInfo.cs @@ -0,0 +1,28 @@ +using SharpTS.Parsing; + +namespace SharpTS.TypeSystem; + +/// +/// Pure-data description of a compiled object-literal local that the IL compiler can promote to a +/// generated value-type "shape" struct with typed fields (#862), instead of the default +/// Dictionary<string, object>. Produced by +/// and stored on the ; +/// the Reflection.Emit side (ObjectShapeRegistry) maps a to a +/// generated $Shape_N value type. +/// +/// Order matters: JavaScript object keys are insertion-ordered, so preserves +/// the literal's property order. That order defines both the struct's field layout and the +/// used to de-duplicate identical shapes across the program. +/// +/// Lives in TypeSystem/ and references no System.Reflection.Emit type, so it can ride +/// the (which is threaded into every emit context) without coupling the type system +/// to the compiler back end. +/// +public sealed record ObjectShapeInfo(string CanonicalKey, IReadOnlyList Fields); + +/// +/// A single promotable field of an : its property name and primitive kind +/// (double, bool, +/// or string). +/// +public readonly record struct ObjectShapeField(string Name, TokenType Kind); diff --git a/TypeSystem/TypeMap.cs b/TypeSystem/TypeMap.cs index e283b652..13a4b8b8 100644 --- a/TypeSystem/TypeMap.cs +++ b/TypeSystem/TypeMap.cs @@ -21,6 +21,7 @@ public class TypeMap private readonly HashSet _undefinedReachableNumericLocals = new(ReferenceEqualityComparer.Instance); private readonly HashSet _undefinedReachableNumericParams = new(ReferenceEqualityComparer.Instance); private readonly Dictionary _promotableArrayLocals = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _promotableObjectLocals = new(ReferenceEqualityComparer.Instance); /// /// Associates an expression with its resolved type. @@ -144,6 +145,33 @@ public void MarkPromotableArrayLocal(Token nameToken, TokenType elementToken) => public bool IsPromotableArrayLocal(Token nameToken, out TokenType elementToken) => _promotableArrayLocals.TryGetValue(nameToken, out elementToken); + /// + /// Flags a const/let object-literal local declaration whose literal has a fixed, + /// statically-known primitive shape and which is provably non-escaping (only used via constant-key + /// field read/write). The IL compiler promotes such a local to a generated value-type "shape" struct + /// with typed fields (#862) instead of the default Dictionary<string, object>. Keyed by + /// reference on the declaration's name token (stable across both Stmt.Var and + /// Stmt.Const). Purely a compiler hint — set by the IL compiler's promotion analyzer, not by + /// the type checker. + /// + public void MarkPromotableObjectLocal(Token nameToken, ObjectShapeInfo shape) => + _promotableObjectLocals[nameToken] = shape; + + /// + /// If the declaration with name token was flagged by + /// , returns true and sets to its + /// shape; otherwise false. + /// + public bool IsPromotableObjectLocal(Token nameToken, out ObjectShapeInfo shape) => + _promotableObjectLocals.TryGetValue(nameToken, out shape!); + + /// + /// All distinct shapes flagged by (one entry per marked + /// local; the IL compiler de-duplicates by when defining + /// the generated types). Empty when no object local was promoted. + /// + public IEnumerable PromotableObjectLocalShapes => _promotableObjectLocals.Values; + /// /// Gets the resolved type for an expression, or null if not found. ///