Skip to content

Commit b8b1c4a

Browse files
author
nickna
committed
perf: typed-record fast path for property writes
Symmetric to the prior commit's read-side fast path. `obj.x = v` currently dispatches through `$Runtime.SetProperty(obj, "x", v)`, which does a frozen check, a sealed check, then walks an isinst chain ($TSObject / IHasFields / TSError / Dict / ...) before reaching the actual store. The chain costs ~10 ns per write on hot paths. When the receiver's static type is `TypeInfo.Record`, take a direct `Dictionary<string, object>.set_Item` path. Bails to SetProperty when the receiver is frozen, sealed, or not actually a bare Dictionary at runtime — keeps Object.freeze / Object.seal sloppy-mode semantics intact (frozen → silent no-op, sealed + new property → silent no-op, sealed + existing property → succeeds via slow path's `ContainsKey` branch). Skipped under strict mode entirely so SetPropertyStrict's TypeError surface stays untouched. Wins on the property-access benchmark, N=1M, .NET 10 / Arm64: SinglePropWriteLoop 20.4 ms → 12.4 ms -39% Allocations unchanged (24 MB is operand boxing of `i` per iter — typed-storage rework needed to eliminate). xUnit: 10310/10310. Standalone-DLL constraint preserved.
1 parent 06ed7a9 commit b8b1c4a

3 files changed

Lines changed: 120 additions & 0 deletions

File tree

Compilation/ILEmitter.Properties.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,89 @@ private void EmitTypedRecordPropertyGet(Expr.Get g)
379379
SetStackUnknown();
380380
}
381381

382+
/// <summary>
383+
/// Phase I fast path for <c>obj.x = v</c> on a record-typed receiver.
384+
/// Symmetric to <see cref="EmitTypedRecordPropertyGet"/>, but with
385+
/// extra guards for the spec-mandated semantics SetProperty handles:
386+
/// <list type="bullet">
387+
/// <item>Object.freeze: silent fail in sloppy mode.</item>
388+
/// <item>Object.seal: existing-property writes succeed, new-property
389+
/// adds silently fail.</item>
390+
/// <item>Object.preventExtensions: tracked via PropertyDescriptorStore;
391+
/// fall back to slow path which calls PDSCanAddProperty.</item>
392+
/// </list>
393+
/// We check FrozenObjects/SealedObjects directly; on hit, route to
394+
/// the slow path. For the non-frozen, non-sealed common case we go
395+
/// straight to <c>dict.set_Item</c>. Skipping the long isinst chain
396+
/// inside SetProperty saves ~10 ns/call on hot paths.
397+
///
398+
/// Stack on entry: empty. Stack on exit: <c>[boxedValue]</c> — the
399+
/// assignment expression's result, matching the slow path.
400+
/// </summary>
401+
private void EmitTypedRecordPropertySet(Expr.Set s)
402+
{
403+
EmitExpression(s.Object);
404+
EmitBoxIfNeeded(s.Object);
405+
var receiverLocal = IL.DeclareLocal(_ctx.Types.Object);
406+
IL.Emit(OpCodes.Stloc, receiverLocal);
407+
408+
EmitExpression(s.Value);
409+
EmitBoxIfNeeded(s.Value);
410+
var valueLocal = IL.DeclareLocal(_ctx.Types.Object);
411+
IL.Emit(OpCodes.Stloc, valueLocal);
412+
413+
var fallbackLabel = IL.DefineLabel();
414+
var endLabel = IL.DefineLabel();
415+
var ignoredLocal = IL.DeclareLocal(_ctx.Types.Object);
416+
var cwtTryGet = _ctx.Types.GetMethod(
417+
_ctx.Types.ConditionalWeakTable, "TryGetValue",
418+
_ctx.Types.Object, _ctx.Types.Object.MakeByRefType());
419+
420+
// Bail to slow path on Object.freeze/seal — keeps spec semantics
421+
// intact without having to replicate the property-descriptor
422+
// dance here. Check FrozenObjects first; then SealedObjects.
423+
IL.Emit(OpCodes.Ldsfld, _ctx.Runtime!.FrozenObjectsField);
424+
IL.Emit(OpCodes.Ldloc, receiverLocal);
425+
IL.Emit(OpCodes.Ldloca, ignoredLocal);
426+
IL.Emit(OpCodes.Callvirt, cwtTryGet);
427+
IL.Emit(OpCodes.Brtrue, fallbackLabel);
428+
429+
IL.Emit(OpCodes.Ldsfld, _ctx.Runtime!.SealedObjectsField);
430+
IL.Emit(OpCodes.Ldloc, receiverLocal);
431+
IL.Emit(OpCodes.Ldloca, ignoredLocal);
432+
IL.Emit(OpCodes.Callvirt, cwtTryGet);
433+
IL.Emit(OpCodes.Brtrue, fallbackLabel);
434+
435+
// dictLocal = receiver as Dictionary<string, object>; if null,
436+
// not the shape we're optimized for → fall back.
437+
var dictLocal = IL.DeclareLocal(_ctx.Types.DictionaryStringObject);
438+
IL.Emit(OpCodes.Ldloc, receiverLocal);
439+
IL.Emit(OpCodes.Isinst, _ctx.Types.DictionaryStringObject);
440+
IL.Emit(OpCodes.Stloc, dictLocal);
441+
IL.Emit(OpCodes.Ldloc, dictLocal);
442+
IL.Emit(OpCodes.Brfalse, fallbackLabel);
443+
444+
// dict[name] = value
445+
IL.Emit(OpCodes.Ldloc, dictLocal);
446+
IL.Emit(OpCodes.Ldstr, s.Name.Lexeme);
447+
IL.Emit(OpCodes.Ldloc, valueLocal);
448+
var setItem = _ctx.Types.GetMethod(
449+
_ctx.Types.DictionaryStringObject, "set_Item",
450+
_ctx.Types.String, _ctx.Types.Object);
451+
IL.Emit(OpCodes.Callvirt, setItem);
452+
IL.Emit(OpCodes.Br, endLabel);
453+
454+
IL.MarkLabel(fallbackLabel);
455+
IL.Emit(OpCodes.Ldloc, receiverLocal);
456+
IL.Emit(OpCodes.Ldstr, s.Name.Lexeme);
457+
IL.Emit(OpCodes.Ldloc, valueLocal);
458+
IL.Emit(OpCodes.Call, _ctx.Runtime!.SetProperty);
459+
460+
IL.MarkLabel(endLabel);
461+
IL.Emit(OpCodes.Ldloc, valueLocal);
462+
SetStackUnknown();
463+
}
464+
382465
protected override void EmitSet(Expr.Set s)
383466
{
384467
// CommonJS: `module.exports = X` writes → stsfld $exports
@@ -488,6 +571,24 @@ protected override void EmitSet(Expr.Set s)
488571
}
489572
}
490573

574+
// Phase I fast path: symmetric to the EmitGet typed-record fast
575+
// path. When the receiver's static type is `TypeInfo.Record`, the
576+
// runtime value is most often a bare `Dictionary<string, object>`
577+
// produced by EmitObjectLiteral. Bypass SetProperty's isinst
578+
// chain with a direct `Castclass Dictionary; set_Item` on the
579+
// common case, falling through to SetProperty for non-Dict
580+
// shapes ($Object with setters, class instances, etc.).
581+
// Skipped under strict mode — SetPropertyStrict surfaces a
582+
// TypeError for assignments to read-only properties / sealed
583+
// objects, which we can't replicate in IL without re-doing the
584+
// dispatch chain.
585+
if (!_ctx.IsStrictMode
586+
&& objType is TypeInfo.Record)
587+
{
588+
EmitTypedRecordPropertySet(s);
589+
return;
590+
}
591+
491592
// Build stack for SetProperty(obj, name, value) or SetPropertyStrict(obj, name, value, strictMode)
492593
EmitExpression(s.Object);
493594
EmitBoxIfNeeded(s.Object);

SharpTS.Benchmarks/Benchmarks/PropertyAccessBenchmarks.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class PropertyAccessBenchmarks
2121
private MethodInfo _tsChain = null!;
2222
private MethodInfo _tsMethodCall = null!;
2323
private MethodInfo _tsClassProp = null!;
24+
private MethodInfo _tsPropWrite = null!;
2425

2526
[Params(100, 10_000, 1_000_000)]
2627
public int N { get; set; }
@@ -42,6 +43,7 @@ public void Setup()
4243
_tsChain = BenchmarkHarness.GetCompiledMethod(_tsAssembly, "chainPropLoop");
4344
_tsMethodCall = BenchmarkHarness.GetCompiledMethod(_tsAssembly, "methodCallLoop");
4445
_tsClassProp = BenchmarkHarness.GetCompiledMethod(_tsAssembly, "classPropLoop");
46+
_tsPropWrite = BenchmarkHarness.GetCompiledMethod(_tsAssembly, "singlePropWriteLoop");
4547
}
4648

4749
[Benchmark]
@@ -63,4 +65,9 @@ public void Setup()
6365
[BenchmarkCategory("ClassPropLoop")]
6466
public object? SharpTS_ClassPropLoop()
6567
=> BenchmarkHarness.InvokeCompiled(_tsClassProp, (double)N);
68+
69+
[Benchmark]
70+
[BenchmarkCategory("SinglePropWriteLoop")]
71+
public object? SharpTS_SinglePropWriteLoop()
72+
=> BenchmarkHarness.InvokeCompiled(_tsPropWrite, (double)N);
6673
}

SharpTS.Benchmarks/TypeScriptSources/PropertyAccess.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,15 @@ function classPropLoop(n: number): number {
6060
}
6161
return total;
6262
}
63+
64+
// Property write — `obj.x = v`. Tight loop assigning to the same property
65+
// each iteration on a single object. Measures dispatch cost of SetProperty.
66+
// Explicit annotation keeps `x` widened to number rather than literal 0,
67+
// otherwise the type checker rejects the iteration assignment.
68+
function singlePropWriteLoop(n: number): number {
69+
const obj: { x: number, y: number } = { x: 0, y: 0 };
70+
for (let i: number = 0; i < n; i++) {
71+
obj.x = i;
72+
}
73+
return obj.x as number;
74+
}

0 commit comments

Comments
 (0)