diff --git a/source/Handlebars/BindingContext.cs b/source/Handlebars/BindingContext.cs index f7875884..42b1b38b 100644 --- a/source/Handlebars/BindingContext.cs +++ b/source/Handlebars/BindingContext.cs @@ -66,6 +66,7 @@ internal void SetDataObject(object? data) private void Initialize() { Root = ParentContext?.Root ?? this; + HasFrameHelpers = ParentContext?.HasFrameHelpers ?? false; ContextDataObject.AddOrReplace(ChainSegment.Root, Root.Value, out WellKnownVariables[(int) WellKnownVariable.Root]); @@ -117,9 +118,17 @@ out WellKnownVariables[(int) WellKnownVariable.Parent] internal CascadeIndex, StringEqualityComparer> BlockHelpers { get; } internal TemplateDelegate? PartialBlockTemplate { get; set; } - + internal short PartialDepth { get; set; } + /// + /// true once any code obtained this frame's (or an ancestor frame's) helper + /// registries for writing — see . Until then the + /// per-frame helper chain is known to be empty, letting hot paths + /// () skip the cascade lookup entirely. + /// + internal bool HasFrameHelpers { get; set; } + public object? Value { get; set; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -203,8 +212,16 @@ private static void PopulateHash(HashParameterDictionary hash, object from) } } - IIndexed> IHelpersRegistry.GetHelpers() => Helpers; + IIndexed> IHelpersRegistry.GetHelpers() + { + HasFrameHelpers = true; + return Helpers; + } - IIndexed> IHelpersRegistry.GetBlockHelpers() => BlockHelpers; + IIndexed> IHelpersRegistry.GetBlockHelpers() + { + HasFrameHelpers = true; + return BlockHelpers; + } } } diff --git a/source/Handlebars/Decorators/BlockDecoratorOptions.cs b/source/Handlebars/Decorators/BlockDecoratorOptions.cs index f5bf6b82..747f9057 100644 --- a/source/Handlebars/Decorators/BlockDecoratorOptions.cs +++ b/source/Handlebars/Decorators/BlockDecoratorOptions.cs @@ -84,8 +84,8 @@ public void Template(in EncodedTextWriter writer, object? context) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Template(in EncodedTextWriter writer, BindingContext context) => OriginalTemplate(writer, context); - IIndexed> IHelpersRegistry.GetHelpers() => Frame.Helpers; + IIndexed> IHelpersRegistry.GetHelpers() => ((IHelpersRegistry) Frame).GetHelpers(); - IIndexed> IHelpersRegistry.GetBlockHelpers() => Frame.BlockHelpers; + IIndexed> IHelpersRegistry.GetBlockHelpers() => ((IHelpersRegistry) Frame).GetBlockHelpers(); } } \ No newline at end of file diff --git a/source/Handlebars/Decorators/DecoratorOptions.cs b/source/Handlebars/Decorators/DecoratorOptions.cs index d6e39294..ef6008c1 100644 --- a/source/Handlebars/Decorators/DecoratorOptions.cs +++ b/source/Handlebars/Decorators/DecoratorOptions.cs @@ -25,8 +25,8 @@ BindingContext frame public PathInfo Name { get; } - IIndexed> IHelpersRegistry.GetHelpers() => Frame.Helpers; + IIndexed> IHelpersRegistry.GetHelpers() => ((IHelpersRegistry) Frame).GetHelpers(); - IIndexed> IHelpersRegistry.GetBlockHelpers() => Frame.BlockHelpers; + IIndexed> IHelpersRegistry.GetBlockHelpers() => ((IHelpersRegistry) Frame).GetBlockHelpers(); } } \ No newline at end of file diff --git a/source/Handlebars/Extensions/EnumerableExtensions.cs b/source/Handlebars/Extensions/EnumerableExtensions.cs index 73ce2fd5..157ab8f5 100644 --- a/source/Handlebars/Extensions/EnumerableExtensions.cs +++ b/source/Handlebars/Extensions/EnumerableExtensions.cs @@ -12,7 +12,14 @@ internal static class EnumerableExtensions public static bool Any(this IEnumerable builder) { var enumerator = builder.GetEnumerator(); - return enumerator.MoveNext(); + try + { + return enumerator.MoveNext(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } } public static bool IsOneOf(this IEnumerable source) diff --git a/source/Handlebars/HandlebarsUtils.cs b/source/Handlebars/HandlebarsUtils.cs index a5dedc18..455d5861 100644 --- a/source/Handlebars/HandlebarsUtils.cs +++ b/source/Handlebars/HandlebarsUtils.cs @@ -40,11 +40,25 @@ public static bool IsFalsy([NotNullWhen(false)] object? value, bool includeZero) return IsFalsyJsonElement(element, includeZero); } - if (IsNumber(value) && !includeZero) + if (includeZero) return false; + + // Typed zero checks in likelihood order; avoids IsNumber's isinst cascade + // followed by Convert.ToBoolean's IConvertible dispatch on the hot path. + return value switch { - return !Convert.ToBoolean(value); - } - return false; + int i => i == 0, + long l => l == 0L, + double d => d == 0d, + float f => f == 0f, + decimal m => m == 0m, + byte b8 => b8 == 0, + sbyte s8 => s8 == 0, + short s16 => s16 == 0, + ushort u16 => u16 == 0, + uint u32 => u32 == 0u, + ulong u64 => u64 == 0ul, + _ => false + }; } private static bool IsFalsyJsonElement(JsonElement element, bool includeZero) @@ -84,23 +98,13 @@ public static bool IsFalsyOrEmpty([NotNullWhen(false)] object? value, bool inclu || (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 0); } + // O(1) emptiness checks that avoid allocating an enumerator for the common + // collection shapes; the IEnumerable fallback boxes List-style struct enumerators. + if (value is ICollection collection) return collection.Count == 0; + return value is IEnumerable enumerable && !enumerable.Any(); } - private static bool IsNumber([NotNullWhen(true)] object? value) - { - return value is sbyte - || value is byte - || value is short - || value is ushort - || value is int - || value is uint - || value is long - || value is ulong - || value is float - || value is double - || value is decimal; - } } } diff --git a/source/Handlebars/Helpers/BlockHelpers/LateBindBlockHelperDescriptor.cs b/source/Handlebars/Helpers/BlockHelpers/LateBindBlockHelperDescriptor.cs index c32c53e5..a5ca439b 100644 --- a/source/Handlebars/Helpers/BlockHelpers/LateBindBlockHelperDescriptor.cs +++ b/source/Handlebars/Helpers/BlockHelpers/LateBindBlockHelperDescriptor.cs @@ -1,3 +1,5 @@ +using System; +using System.Runtime.CompilerServices; using HandlebarsDotNet.Collections; using HandlebarsDotNet.PathStructure; @@ -5,6 +7,12 @@ namespace HandlebarsDotNet.Helpers.BlockHelpers { public sealed class LateBindBlockHelperDescriptor : IHelperDescriptor { + // See LateBindHelperDescriptor: ObservableList.Count locks per call, so the + // "are there helper resolvers" check is cached behind an observer-maintained flag. + private ObservableList? _observedResolvers; + private IObserver>? _observerRoot; // strong root: ObservableList holds observers weakly + private volatile bool _hasResolvers; + public LateBindBlockHelperDescriptor(string name) => Name = name; public PathInfo Name { get; } @@ -16,16 +24,18 @@ public object Invoke(in BlockHelperOptions options, in Context context, in Argum public void Invoke(in EncodedTextWriter output, in BlockHelperOptions options, in Context context, in Arguments arguments) { - if(options.Frame.BlockHelpers.TryGetValue(Name, out var contextHelper)) + // Frame-local helpers only exist once something wrote to a frame's helper registry + // (decorators / in-render registration); skip the cascade walk in the common case. + if(options.Frame.HasFrameHelpers && options.Frame.BlockHelpers.TryGetValue(Name, out var contextHelper)) { contextHelper.Invoke(options, context, arguments); return; } - - // TODO: add cache + var configuration = options.Frame.Configuration; var helperResolvers = (ObservableList) configuration.HelperResolvers; - if(helperResolvers.Count != 0) + if (!ReferenceEquals(_observedResolvers, helperResolvers)) ObserveResolvers(helperResolvers); + if (_hasResolvers) { for (var index = 0; index < helperResolvers.Count; index++) { @@ -39,5 +49,19 @@ public void Invoke(in EncodedTextWriter output, in BlockHelperOptions options, i configuration.BlockHelpers["blockHelperMissing"]!.Value .Invoke(output, options, context, arguments); } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ObserveResolvers(ObservableList resolvers) + { + // Subscribe before snapshotting Count so a concurrent Add can never be missed; + // duplicate subscriptions from a racing first call are benign (same flag). + var observer = ObserverBuilder>.Create(this) + .OnEvent>((_, state) => state._hasResolvers = true) + .Build(); + resolvers.Subscribe(observer); + _observerRoot = observer; + if (resolvers.Count != 0) _hasResolvers = true; + _observedResolvers = resolvers; + } } -} \ No newline at end of file +} diff --git a/source/Handlebars/Helpers/LateBindHelperDescriptor.cs b/source/Handlebars/Helpers/LateBindHelperDescriptor.cs index af49fa7e..d69d29ea 100644 --- a/source/Handlebars/Helpers/LateBindHelperDescriptor.cs +++ b/source/Handlebars/Helpers/LateBindHelperDescriptor.cs @@ -1,3 +1,5 @@ +using System; +using System.Runtime.CompilerServices; using HandlebarsDotNet.Collections; using HandlebarsDotNet.PathStructure; @@ -5,6 +7,13 @@ namespace HandlebarsDotNet.Helpers { public sealed class LateBindHelperDescriptor : IHelperDescriptor { + // ObservableList.Count takes a ReaderWriterLockSlim per call, and this descriptor runs + // for every simple {{name}} on every render — so the "are there helper resolvers" check + // is cached in a flag kept up to date by subscribing to the (append-only) resolver list. + private ObservableList? _observedResolvers; + private IObserver>? _observerRoot; // strong root: ObservableList holds observers weakly + private volatile bool _hasResolvers; + public LateBindHelperDescriptor(string name) => Name = name; public PathInfo Name { get; } @@ -13,15 +22,17 @@ public sealed class LateBindHelperDescriptor : IHelperDescriptor { var bindingContext = options.Frame; - if(options.Frame.Helpers.TryGetValue(Name, out var contextHelper)) + // Frame-local helpers only exist once something wrote to a frame's helper registry + // (decorators / in-render registration); skip the cascade walk in the common case. + if(bindingContext.HasFrameHelpers && bindingContext.Helpers.TryGetValue(Name, out var contextHelper)) { return contextHelper.Invoke(options, context, arguments); } - - // TODO: add cache + var configuration = options.Frame.Configuration; var helperResolvers = (ObservableList) configuration.HelperResolvers; - if (helperResolvers.Count != 0) + if (!ReferenceEquals(_observedResolvers, helperResolvers)) ObserveResolvers(helperResolvers); + if (_hasResolvers) { var targetType = arguments.Length > 0 ? arguments[0]!.GetType() : null; for (var index = 0; index < helperResolvers.Count; index++) @@ -35,7 +46,7 @@ public sealed class LateBindHelperDescriptor : IHelperDescriptor var value = PathResolver.ResolvePath(bindingContext, Name); if (!(value is UndefinedBindingResult)) return value; - + return configuration.Helpers["helperMissing"]!.Value.Invoke(options, context, arguments); } @@ -43,5 +54,19 @@ public void Invoke(in EncodedTextWriter output, in HelperOptions options, in Con { output.Write(Invoke(options, context, arguments)); } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ObserveResolvers(ObservableList resolvers) + { + // Subscribe before snapshotting Count so a concurrent Add can never be missed; + // duplicate subscriptions from a racing first call are benign (same flag). + var observer = ObserverBuilder>.Create(this) + .OnEvent>((_, state) => state._hasResolvers = true) + .Build(); + resolvers.Subscribe(observer); + _observerRoot = observer; + if (resolvers.Count != 0) _hasResolvers = true; + _observedResolvers = resolvers; + } } -} \ No newline at end of file +} diff --git a/source/Handlebars/IO/PolledStringWriter.cs b/source/Handlebars/IO/PolledStringWriter.cs index 0bc67532..d6db13b0 100644 --- a/source/Handlebars/IO/PolledStringWriter.cs +++ b/source/Handlebars/IO/PolledStringWriter.cs @@ -7,7 +7,11 @@ namespace HandlebarsDotNet { public class ReusableStringWriter : StringWriter { - private static readonly InternalObjectPool Pool = new InternalObjectPool(new Policy(16)); + // Retain up to 32K chars (64KB, below the LOH threshold) so that templates producing + // typical page-sized output keep their grown StringBuilder across renders. The previous + // 4096-char limit made any render larger than 4KB discard the writer, forcing every + // subsequent render to re-grow a fresh StringBuilder(16) chunk by chunk. + private static readonly InternalObjectPool Pool = new InternalObjectPool(new Policy(16, 32 * 1024)); private IFormatProvider _formatProvider = null!; diff --git a/source/Handlebars/IO/SafeStrings.cs b/source/Handlebars/IO/SafeStrings.cs index 09c444e7..94bdd81d 100644 --- a/source/Handlebars/IO/SafeStrings.cs +++ b/source/Handlebars/IO/SafeStrings.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Threading; namespace HandlebarsDotNet.IO { @@ -16,15 +17,26 @@ internal static class SafeStrings private static readonly ConditionalWeakTable Marked = new(); private static readonly object Sentinel = new(); + // Marking only ever happens when a helper's captured output is written (ReturnInvoke). + // Most applications never mark a single string, yet IsSafe sits on the hot path of every + // string written to output — so keep a global "has anything ever been marked" latch to + // skip the ConditionalWeakTable probe entirely until the first Mark. The latch is written + // with release semantics after the table entry exists, so a true reader always observes + // the corresponding table entry; a stale false reader merely re-encodes on the same + // thread-interleaving that was already possible before the mark completed. + private static bool _anyMarked; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string Mark(string value) { if (value.Length == 0) return value; Marked.GetValue(value, _ => Sentinel); + Volatile.Write(ref _anyMarked, true); return value; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool IsSafe(string value) => value.Length == 0 || Marked.TryGetValue(value, out _); + public static bool IsSafe(string value) + => value.Length == 0 || (Volatile.Read(ref _anyMarked) && Marked.TryGetValue(value, out _)); } } diff --git a/source/Handlebars/ObjectDescriptors/ObjectDescriptorFactory.cs b/source/Handlebars/ObjectDescriptors/ObjectDescriptorFactory.cs index 134d2196..e568abf8 100644 --- a/source/Handlebars/ObjectDescriptors/ObjectDescriptorFactory.cs +++ b/source/Handlebars/ObjectDescriptors/ObjectDescriptorFactory.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Threading; using HandlebarsDotNet.Collections; using HandlebarsDotNet.EqualityComparers; using HandlebarsDotNet.Runtime; @@ -25,18 +26,31 @@ public class ObjectDescriptorFactory : IObjectDescriptorProvider, IObserver> _observer; + private int _version; + public static ObjectDescriptorFactory? Current => AmbientContext.Current?.ObjectDescriptorFactory; - + + /// + /// Monotonic stamp bumped whenever the provider set changes; lets external + /// per-call-site descriptor caches (see ) + /// detect that previously resolved descriptors may be stale. + /// + internal int Version => Volatile.Read(ref _version); + public ObjectDescriptorFactory(ObservableList? providers = null) { _providers = new ObservableList(); - + if (providers != null) Append(providers); - - _observer = ObserverBuilder>.Create(_descriptorsCache) - .OnEvent>((@event, state) => state.Clear()) + + _observer = ObserverBuilder>.Create(this) + .OnEvent>((@event, state) => + { + state._descriptorsCache.Clear(); + Interlocked.Increment(ref state._version); + }) .Build(); - + _providers.Subscribe(this); } diff --git a/source/Handlebars/PathStructure/ChainSegment.cs b/source/Handlebars/PathStructure/ChainSegment.cs index 4fa819aa..75b2794e 100644 --- a/source/Handlebars/PathStructure/ChainSegment.cs +++ b/source/Handlebars/PathStructure/ChainSegment.cs @@ -93,6 +93,63 @@ internal ChainSegment(string value, WellKnownVariable wellKnownVariable = WellKn internal readonly WellKnownVariable WellKnownVariable; + // Monomorphic inline cache for render-time member access (see PathResolver.TryAccessMember): + // most call sites see one instance type under one descriptor factory, so a single + // immutable (factory, version, type) -> descriptor entry avoids the ambient-context + // probe plus type-keyed dictionary lookup on every access. Reference assignment keeps + // readers consistent; a stale entry is invalidated by the factory version stamp. + private DescriptorCacheEntry? _descriptorCache; + + private sealed class DescriptorCacheEntry + { + public readonly ObjectDescriptors.ObjectDescriptorFactory Factory; + public readonly int Version; + public readonly Type Type; + public readonly ObjectDescriptors.ObjectDescriptor Descriptor; + + public DescriptorCacheEntry(ObjectDescriptors.ObjectDescriptorFactory factory, int version, Type type, ObjectDescriptors.ObjectDescriptor descriptor) + { + Factory = factory; + Version = version; + Type = type; + Descriptor = descriptor; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal ObjectDescriptors.ObjectDescriptor GetDescriptorFor(object instance) + { + var factory = ObjectDescriptors.ObjectDescriptorFactory.Current; + if (factory == null) return ObjectDescriptors.ObjectDescriptor.Empty; + + var type = instance.GetType(); + var cache = _descriptorCache; + if (cache != null + && ReferenceEquals(cache.Factory, factory) + && ReferenceEquals(cache.Type, type) + && cache.Version == factory.Version) + { + return cache.Descriptor; + } + + return GetDescriptorSlow(factory, type); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private ObjectDescriptors.ObjectDescriptor GetDescriptorSlow(ObjectDescriptors.ObjectDescriptorFactory factory, Type type) + { + // Read the version before resolving so a concurrent provider registration can only + // produce an entry that immediately misses (never a stale entry stamped fresh). + var version = factory.Version; + if (!factory.TryGetDescriptor(type, out var descriptor)) + { + descriptor = ObjectDescriptors.ObjectDescriptor.Empty; + } + + _descriptorCache = new DescriptorCacheEntry(factory, version, type, descriptor); + return descriptor; + } + /// /// Returns string representation of current /// diff --git a/source/Handlebars/PathStructure/PathResolver.cs b/source/Handlebars/PathStructure/PathResolver.cs index 9c500610..74a95be9 100644 --- a/source/Handlebars/PathStructure/PathResolver.cs +++ b/source/Handlebars/PathStructure/PathResolver.cs @@ -12,7 +12,6 @@ public static class PathResolver if (pathInfo.IsPureThis) return context.Value; var instance = context.Value; - var throwOnUnresolvedBindingExpression = context.Configuration.ThrowOnUnresolvedBindingExpression; var segments = pathInfo.Segments; for (var segmentIndex = 0; segmentIndex < segments.Length; segmentIndex++) @@ -21,15 +20,15 @@ public static class PathResolver if (segment.IsThis) continue; if (segment.IsParent) { - context = context.ParentContext!; - if (context == null!) + var parent = context.ParentContext; + if (parent == null) { instance = UndefinedBindingResult.Create(".."); goto undefined; } + context = parent; instance = context.Value; - throwOnUnresolvedBindingExpression = context.Configuration.ThrowOnUnresolvedBindingExpression; continue; } @@ -52,7 +51,9 @@ public static class PathResolver return instance; undefined: - if (throwOnUnresolvedBindingExpression) + // Only consult configuration on the (rare) unresolved branch; reading it eagerly + // costs two dispatched property reads per resolve on the hot path. + if (context.Configuration.ThrowOnUnresolvedBindingExpression) { Throw.Undefined(pathInfo, (UndefinedBindingResult) instance); } @@ -92,8 +93,15 @@ public static bool TryAccessMember(BindingContext context, object? instance, Cha } chainSegment = ResolveMemberName(instance, chainSegment, context.Configuration); - - return new ObjectAccessor(instance).TryGetValue(chainSegment, out value); + + var accessor = chainSegment.GetDescriptorFor(instance).MemberAccessor; + if (accessor == null) + { + value = null!; + return false; + } + + return accessor.TryGetValue(instance, chainSegment, out value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/source/Handlebars/Pools/BindingContext.Pool.cs b/source/Handlebars/Pools/BindingContext.Pool.cs index e2c4ada6..fb337e6f 100644 --- a/source/Handlebars/Pools/BindingContext.Pool.cs +++ b/source/Handlebars/Pools/BindingContext.Pool.cs @@ -48,6 +48,7 @@ public bool Return(BindingContext item) { item.Configuration = null!; + item.HasFrameHelpers = false; item.Root = null!; item.Value = null!; item.ParentContext = null;