Skip to content

JsonLayout - Faster Json encoding with INoAllocationStringValueRenderer#6082

Merged
snakefoot merged 2 commits intoNLog:devfrom
snakefoot:JsonAttributeStringValue
Jan 31, 2026
Merged

JsonLayout - Faster Json encoding with INoAllocationStringValueRenderer#6082
snakefoot merged 2 commits intoNLog:devfrom
snakefoot:JsonAttributeStringValue

Conversation

@snakefoot
Copy link
Copy Markdown
Contributor

@snakefoot snakefoot commented Jan 27, 2026

It is much faster to enumerate a string to check if Json-encoding needed, than appending string to StringBuilder and enumerate the StringBuilder to check if Json-encoding needed.

When NLog is used as Microsoft Logging Provider, then it will by default generate simple LogEvents (message formatting done upfront).

My own use-case is using NLog for writing string-blobs into a json-file, and want to reduce the overhead (writing 10000 blobs/sec)

Method Mean Error StdDev Gen0 Allocated
Before-PR 344.5 us 1.34 us 1.25 us 21.9727 203.13 KB
After-PR 248.1 us 0.78 us 0.69 us 21.9727 203.13 KB

With the following JsonLayout, and the log-message "The cat sat at the bar.".

            var jsonLayout = new NLog.Layouts.JsonLayout
            {
                Attributes =
                {
                    new NLog.Layouts.JsonAttribute("time", "${date:format=o}"),
                    new NLog.Layouts.JsonAttribute("level", "${level:upperCase=true}"),
                    new NLog.Layouts.JsonAttribute("message", "${message}"),
                    new NLog.Layouts.JsonAttribute("logger", "${logger}"),
                }
            };

Even for a very small payloads, then there is a 50pct speed-improvement. And when increasing log-message to 500 characters, then 250pct speed-improvement:

Method Mean Error StdDev Gen0 Allocated
Before-PR 1.404 ms 0.0055 ms 0.0051 ms 21.4844 203.13 KB
After-PR 570.0 us 3.08 us 2.73 us 21.4844 203.13 KB

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 27, 2026

Walkthrough

Adds an allocation-free string-rendering path: introduces internal INoAllocationStringValueRenderer, makes IStringValueRenderer internal, caches simple string renderers via ValueTypeLayoutInfo.SimpleStringValue, updates multiple layout renderers to provide no-allocation rendering, and adds an optimized JSON string rendering fast-path in JsonAttribute.

Changes

Cohort / File(s) Summary
New Internal Interface
src/NLog/Internal/INoAllocationStringValueRenderer.cs
Adds INoAllocationStringValueRenderer with GetFormattedStringNoAllocation(LogEventInfo) for allocation-free string rendering.
Interface Visibility Change
src/NLog/Internal/IStringValueRenderer.cs
Makes IStringValueRenderer internal and updates remarks wording about AppDomainFixedOutput.
Simple-string resolver & cache
src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs
Adds SimpleStringValue property, sets it during layout/value-type assignment, and adds ResolveStringValueMethod(Layout) to detect single-renderer allocation-free renderers.
JSON rendering optimization
src/NLog/Layouts/JSON/JsonAttribute.cs
Introduces RenderAppendJsonStringValue and a fast path that uses SimpleStringValue when Encode is true to emit escaped, quoted strings; falls back to generic value rendering.
Layout renderers — no-allocation path
src/NLog/LayoutRenderers/...
LevelLayoutRenderer.cs, LiteralLayoutRenderer.cs, LoggerNameLayoutRenderer.cs, MessageLayoutRenderer.cs, EventPropertiesLayoutRenderer.cs, GdcLayoutRenderer.cs
Many renderers now implement INoAllocationStringValueRenderer (replacing IStringValueRenderer), adding explicit GetFormattedStringNoAllocation implementations; LevelLayoutRenderer includes a larger refactor for precomputed level name mappings.
JSON serializer / safety tweaks
src/NLog/Targets/DefaultJsonSerializer.cs
Defensive null-coalescing for formattable ToString in NETFRAMEWORK path; string-escape routine adjusted to process empty strings consistently.
AppSetting renderer null-safety
src/NLog/LayoutRenderers/AppSettingLayoutRenderer.cs
GetStringValue() now returns Default ?? string.Empty when Item is empty to ensure non-null result.
Tests
tests/NLog.UnitTests/ApiTests.cs
Extends test exclusion to skip LiteralLayoutRenderer in the AppDomainFixedOutput attribute verification.

Sequence Diagram(s)

sequenceDiagram
  participant Logger as "Logger"
  participant JsonAttr as "JsonAttribute"
  participant VTInfo as "ValueTypeLayoutInfo\n(SimpleStringValue)"
  participant LayoutR as "LayoutRenderer\n(e.g., Message/Level/Literal)"
  participant Serializer as "DefaultJsonSerializer"

  Logger->>JsonAttr: request render attribute
  JsonAttr->>VTInfo: check SimpleStringValue
  alt SimpleStringValue available
    VTInfo-->>JsonAttr: INoAllocationStringValueRenderer
    JsonAttr->>LayoutR: GetFormattedStringNoAllocation(logEvent)
    LayoutR-->>JsonAttr: string? (no-allocation)
    JsonAttr->>Serializer: RenderAppendJsonStringValue(string)
    Serializer-->>JsonAttr: append escaped quoted string
  else No simple renderer
    JsonAttr->>LayoutR: render via layout (object/string)
    LayoutR-->>JsonAttr: object or string
    JsonAttr->>Serializer: generic JSON value rendering (fallback)
  end
  JsonAttr-->>Logger: attribute appended
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I hopped through layouts, light and spry,
Found tiny strings that gave a sigh,
Escaped and quoted, snug and neat,
Saved a byte with every beat,
Carrots crisp — no allocations to eat!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: optimizing JSON encoding for simple log messages, which is the primary focus of all modifications across the changeset.
Description check ✅ Passed The description is directly related to the changeset, explaining the performance motivation and providing benchmark results that validate the optimization strategy for JSON encoding of log messages.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch from 2257bc8 to 51c04c0 Compare January 27, 2026 20:22
@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch from 51c04c0 to e2d9ad1 Compare January 27, 2026 20:33
@snakefoot snakefoot changed the title JsonLayout - Faster Json encoding for simple logevent messages JsonLayout - Faster Json encoding for simple LogEvent messages Jan 27, 2026
@snakefoot snakefoot closed this Jan 28, 2026
@snakefoot snakefoot reopened this Jan 28, 2026
@snakefoot snakefoot closed this Jan 28, 2026
@snakefoot snakefoot reopened this Jan 28, 2026
@pull-request-size pull-request-size bot added size/L and removed size/M labels Jan 28, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/NLog/LayoutRenderers/LevelLayoutRenderer.cs`:
- Around line 103-108: GetLogLevelStringValue currently falls back to
logEvent.ToString() for custom levels, which emits the whole event rather than
just the level text; replace that fallback to call
GetFormattedLevelName(logLevel, _format, _uppercase) so custom LogLevel ordinals
outside _levelNames are rendered using the configured format and casing. Locate
GetLogLevelStringValue and change the else branch (where it uses
logEvent.ToString()) to return GetFormattedLevelName(logLevel, _format,
_uppercase), keeping the existing retrieval of logLevel via GetValue(logEvent)
?? LogLevel.Trace and the ordinal check against _levelNames.
🧹 Nitpick comments (2)
src/NLog/Internal/INoAllocationStringValueRenderer.cs (1)

36-50: LGTM! Well-designed interface for allocation-free rendering.

The interface correctly extends IStringValueRenderer and provides a clear contract where returning null signals the fast path is unavailable. This aligns with the PR's goal of reducing CPU overhead for simple string-valued attributes.

Minor nitpick: The <param name="logEvent"> documentation on line 47 is empty. Consider adding a brief description like "The log event to render."

📝 Suggested documentation improvement
         /// <summary>
         /// Renders the value of layout renderer in the context of the specified log event
         /// </summary>
-        /// <param name="logEvent"></param>
+        /// <param name="logEvent">The log event to render.</param>
         /// <returns>null if not possible or unknown</returns>
         string? GetFormattedStringNoAllocation(LogEventInfo logEvent);
src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs (1)

100-103: Consider restoring SimpleStringValue when ValueType is reset to null.

Right now it stays cleared, so a later ValueType = null loses the fast path.

♻️ Proposed tweak
-                SimpleStringValue = null;
+                SimpleStringValue = value is null ? ResolveStringValueMethod(_layout) : null;

Comment thread src/NLog/LayoutRenderers/LevelLayoutRenderer.cs
@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch 2 times, most recently from 5429cbd to 74bf42e Compare January 28, 2026 20:55
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/NLog/LayoutRenderers/LevelLayoutRenderer.cs`:
- Around line 150-163: GetTriLetterString incorrectly maps LogLevel.Off to the
default trace value; update the method to explicitly handle LogLevel.Off and
return upperCase ? "OFF" : "Off" for that case (leave Trace mapping as the
default/fallback to "TRC"/"Trc"), i.e., add a check for LogLevel.Off in
GetTriLetterString and return the proper three-letter "OFF"/"Off" based on the
upperCase flag so TriLetter format renders Off correctly.
🧹 Nitpick comments (1)
src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs (1)

80-89: Consider avoiding repeated LINQ enumeration.

simpleLayout.LayoutRenderers.Count() and .First() enumerate the collection twice. If LayoutRenderers is IEnumerable<T> without an efficient Count implementation, this incurs overhead on each layout assignment.

♻️ Suggested optimization
 private static INoAllocationStringValueRenderer? ResolveStringValueMethod(Layout layout)
 {
-    var stringValueRenderer = (layout is SimpleLayout simpleLayout && simpleLayout.LayoutRenderers.Count() == 1) ? simpleLayout.LayoutRenderers.First() as INoAllocationStringValueRenderer : null;
+    if (layout is SimpleLayout simpleLayout)
+    {
+        using var enumerator = simpleLayout.LayoutRenderers.GetEnumerator();
+        if (enumerator.MoveNext())
+        {
+            var first = enumerator.Current;
+            if (!enumerator.MoveNext() && first is INoAllocationStringValueRenderer renderer)
+            {
+                var stringValue = renderer.GetFormattedStringNoAllocation(LogEventInfo.CreateNullEvent());
+                return stringValue is null ? null : renderer;
+            }
+        }
+    }
+    return null;
-    if (stringValueRenderer != null)
-    {
-        var stringValue = stringValueRenderer.GetFormattedStringNoAllocation(LogEventInfo.CreateNullEvent());
-        return stringValue is null ? null : stringValueRenderer;
-    }
-    return stringValueRenderer;
 }

Also, line 88 (return stringValueRenderer;) is only reached when stringValueRenderer is null, making it effectively return null;. This isn't a bug but could be simplified for clarity.

Comment thread src/NLog/LayoutRenderers/LevelLayoutRenderer.cs Outdated
@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch 2 times, most recently from 7c65775 to 008bbef Compare January 29, 2026 06:46
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/NLog/LayoutRenderers/Wrappers/CachedLayoutRendererWrapper.cs`:
- Line 180: The no-allocation path
INoAllocationStringValueRenderer.GetFormattedStringNoAllocation must not call
RenderInner (which can allocate and populate the cache); change its
implementation to be side-effect free by returning only an already-populated
cached value when Cached is true (e.g., read the internal cached-value
field/prop without invoking RenderInner) and otherwise return null so probing
with a null event cannot seed the cache; update the method that currently reads
Cached and calls RenderInner to instead only return the cached storage if
present.
🧹 Nitpick comments (1)
src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs (1)

67-88: Avoid double enumeration of LayoutRenderers.

Count() + First() can enumerate twice and adds LINQ overhead. Consider direct list access when there’s exactly one renderer.

♻️ Proposed refactor
-            var stringValueRenderer = (layout is SimpleLayout simpleLayout && simpleLayout.LayoutRenderers.Count() == 1) ? simpleLayout.LayoutRenderers.First() as INoAllocationStringValueRenderer : null;
+            INoAllocationStringValueRenderer? stringValueRenderer = null;
+            if (layout is SimpleLayout simpleLayout && simpleLayout.LayoutRenderers.Count == 1)
+            {
+                stringValueRenderer = simpleLayout.LayoutRenderers[0] as INoAllocationStringValueRenderer;
+            }
             if (stringValueRenderer != null)
             {
                 var stringValue = stringValueRenderer.GetFormattedStringNoAllocation(LogEventInfo.CreateNullEvent());
                 return stringValue is null ? null : stringValueRenderer;
             }
             return stringValueRenderer;

Comment thread src/NLog/LayoutRenderers/Wrappers/CachedLayoutRendererWrapper.cs Outdated
@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch from 008bbef to d6d1016 Compare January 30, 2026 07:22
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/NLog/Internal/IStringValueRenderer.cs`:
- Around line 40-42: The IStringValueRenderer interface was changed from public
to internal which is a public API breaking change; to fix, restore the original
public accessibility on the IStringValueRenderer interface (remove the internal
modifier) so external layout renderers still implement it, update its XML docs
if needed, and run/adjust any visibility-related tests; if the change was
intentional, instead add an explicit entry for IStringValueRenderer in the
project's breaking-changes/compatibility notes and update release notes and
compatibility tests to reflect the intentional removal from the public surface.

In `@src/NLog/LayoutRenderers/EventPropertiesLayoutRenderer.cs`:
- Around line 150-156:
INoAllocationStringValueRenderer.GetFormattedStringNoAllocation currently
ignores when Format is "json" and returns a string, causing JsonAttribute to
escape raw JSON; update the method to return null when Format is "json"
(case-insensitive) in addition to the existing Format == null check, otherwise
keep the existing behavior (use TryGetValue(logEvent, out var value) ? value as
string : string.Empty), referencing the
INoAllocationStringValueRenderer.GetFormattedStringNoAllocation implementation,
the Format property, and the TryGetValue method.

In `@src/NLog/LayoutRenderers/GdcLayoutRenderer.cs`:
- Around line 112-115: The no-allocation renderer path currently ignores json
formatting: update
INoAllocationStringValueRenderer.GetFormattedStringNoAllocation to respect
Format/FormatAsJson by returning null (i.e. opt out of the no-allocation
fast-path) when Format indicates JSON so JsonAttribute can handle proper JSON
serialization; specifically change the method that currently returns Format is
null ? null : GetValue() as string to check FormatAsJson (or Format=="json") and
return null when JSON is requested, otherwise continue returning GetValue() as
string.

In `@src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs`:
- Around line 80-87: ResolveStringValueMethod currently calls
GetFormattedStringNoAllocation with LogEventInfo.CreateNullEvent(), which can
produce false negatives for renderers like MessageLayoutRenderer; remove that
pre-check so the fast-path isn't disabled. In ResolveStringValueMethod: keep the
type check for INoAllocationStringValueRenderer on
simpleLayout.LayoutRenderers.First(), but do not call
GetFormattedStringNoAllocation(LogEventInfo.CreateNullEvent()); instead return
the renderer directly (or if you prefer, test against a representative non-null
event) so runtime calls decide actual behaviour. Ensure references remain to
ResolveStringValueMethod, INoAllocationStringValueRenderer,
GetFormattedStringNoAllocation and SimpleLayout.LayoutRenderers so the change is
easy to locate.
🧹 Nitpick comments (1)
src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs (1)

72-73: Compute SimpleStringValue cache when ValueType is string type, not just when null.

The cache is inconsistently managed: it's only set when ValueType is null, but the codebase treats typeof(string) equivalently (see lines 134, 224, 237). When ValueType is explicitly set to typeof(string) or reset to null, the optimization cache is skipped, reducing performance for the common string type scenario.

Apply the same cache logic to both the Layout setter (line 72) and ValueType setter (line 102):

♻️ Suggested update
-                SimpleStringValue = ValueType is null ? ResolveStringValueMethod(_layout) : null;
+                SimpleStringValue = (ValueType is null || typeof(string).Equals(ValueType))
+                    ? ResolveStringValueMethod(_layout)
+                    : null;
-                SimpleStringValue = null;
+                SimpleStringValue = (value is null || typeof(string).Equals(value))
+                    ? ResolveStringValueMethod(_layout)
+                    : null;

Also applies to: 101-102

Comment thread src/NLog/Internal/IStringValueRenderer.cs
Comment thread src/NLog/LayoutRenderers/EventPropertiesLayoutRenderer.cs
Comment thread src/NLog/LayoutRenderers/GdcLayoutRenderer.cs
Comment thread src/NLog/Layouts/Typed/ValueTypeLayoutInfo.cs
@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch 7 times, most recently from 5a3533c to 98577a7 Compare January 30, 2026 18:10
@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch from 98577a7 to 24bd7f0 Compare January 30, 2026 18:28
@snakefoot snakefoot force-pushed the JsonAttributeStringValue branch from 24bd7f0 to 0e2d166 Compare January 30, 2026 18:44
@sonarqubecloud
Copy link
Copy Markdown

@snakefoot snakefoot merged commit a74027c into NLog:dev Jan 31, 2026
5 of 6 checks passed
@snakefoot snakefoot changed the title JsonLayout - Faster Json encoding for simple LogEvent messages JsonLayout - Faster Json encoding with INoAllocationStringValueRenderer Jan 31, 2026
@snakefoot snakefoot added this to the 6.1.0 milestone Jan 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant