From ab8d54d72d723680c76bf2f884f181b60fb78892 Mon Sep 17 00:00:00 2001 From: GOKHAN ERTOGAN Date: Tue, 20 Oct 2026 22:53:16 +0300 Subject: [PATCH] Avoid allocations in XPath translate() and normalize-space() The XPath implementations of translate() and normalize-space() always copied the input via ToCharArray() and allocated a new string, even when the result was identical to the input. The XSLT runtime had separate StringBuilder-based implementations of the same functions. - translate: skip leading unmapped characters and return the input unchanged when nothing needs mapping. - normalize-space: normalize into a stackalloc/ArrayPool buffer and return the input unchanged when it is already normalized. - XPath StringFunctions now uses the XsltFunctions implementations. --- .../StringFunctionsTests.cs | 72 ++++++++++++++ .../Xml/XPath/Internal/StringFunctions.cs | 50 +--------- .../System/Xml/Xsl/Runtime/XsltFunctions.cs | 99 ++++++++----------- 3 files changed, 118 insertions(+), 103 deletions(-) diff --git a/src/libraries/Common/tests/System/Xml/XPath/CoreFunctionLibrary/StringFunctionsTests.cs b/src/libraries/Common/tests/System/Xml/XPath/CoreFunctionLibrary/StringFunctionsTests.cs index bd97d0c41d070d..f04823fd00f4ab 100644 --- a/src/libraries/Common/tests/System/Xml/XPath/CoreFunctionLibrary/StringFunctionsTests.cs +++ b/src/libraries/Common/tests/System/Xml/XPath/CoreFunctionLibrary/StringFunctionsTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Linq; using System.Xml; using XPathTests.Common; using Xunit; @@ -1064,6 +1065,77 @@ public static void StringFunctionsTest2454(Utils.NavigatorKind kind) Utils.XPathStringTest(kind, xml, testExpression, expected, startingNodePath: startingNodePath); } + /// + /// Verify result. + /// translate("abc", "xyz", "XYZ") = "abc" + /// + [Theory] + [InlineData(Utils.NavigatorKind.XmlDocument)] + [InlineData(Utils.NavigatorKind.XPathDocument)] + [InlineData(Utils.NavigatorKind.XDocument)] + public static void StringFunctionsTestTranslateNoMatch(Utils.NavigatorKind kind) + { + var xml = "dummy.xml"; + var testExpression = @"translate(""abc"", ""xyz"", ""XYZ"")"; + var expected = "abc"; + + Utils.XPathStringTest(kind, xml, testExpression, expected); + } + + /// + /// Verify result for strings longer than the stack buffer used internally. + /// translate(long string, "ab-", "AB") = long string with 'a' and 'b' mapped and '-' removed + /// + [Theory] + [InlineData(Utils.NavigatorKind.XmlDocument)] + [InlineData(Utils.NavigatorKind.XPathDocument)] + [InlineData(Utils.NavigatorKind.XDocument)] + public static void StringFunctionsTestTranslateLongString(Utils.NavigatorKind kind) + { + var xml = "dummy.xml"; + var value = string.Concat(Enumerable.Repeat("xab-", 200)); + var testExpression = @"translate(""" + value + @""", ""ab-"", ""AB"")"; + var expected = string.Concat(Enumerable.Repeat("xAB", 200)); + + Utils.XPathStringTest(kind, xml, testExpression, expected); + } + + /// + /// Verify result for strings longer than the stack buffer used internally. + /// normalize-space(long string) = long string with whitespace normalized + /// + [Theory] + [InlineData(Utils.NavigatorKind.XmlDocument)] + [InlineData(Utils.NavigatorKind.XPathDocument)] + [InlineData(Utils.NavigatorKind.XDocument)] + public static void StringFunctionsTestNormalizeSpaceLongString(Utils.NavigatorKind kind) + { + var xml = "dummy.xml"; + var value = " " + string.Concat(Enumerable.Repeat("ab \t\n\r", 200)) + " "; + var testExpression = @"normalize-space(""" + value + @""")"; + var expected = string.Join(" ", Enumerable.Repeat("ab", 200)); + + Utils.XPathStringTest(kind, xml, testExpression, expected); + } + + /// + /// Verify result for already normalized strings longer than the stack buffer used internally. + /// normalize-space(long normalized string) = long normalized string + /// + [Theory] + [InlineData(Utils.NavigatorKind.XmlDocument)] + [InlineData(Utils.NavigatorKind.XPathDocument)] + [InlineData(Utils.NavigatorKind.XDocument)] + public static void StringFunctionsTestNormalizeSpaceLongNormalizedString(Utils.NavigatorKind kind) + { + var xml = "dummy.xml"; + var value = string.Join(" ", Enumerable.Repeat("ab", 200)); + var testExpression = @"normalize-space(""" + value + @""")"; + var expected = value; + + Utils.XPathStringTest(kind, xml, testExpression, expected); + } + /// /// string(NaN) /// diff --git a/src/libraries/System.Private.Xml/src/System/Xml/XPath/Internal/StringFunctions.cs b/src/libraries/System.Private.Xml/src/System/Xml/XPath/Internal/StringFunctions.cs index a3c759292543b0..a24817c6a9cfcd 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/XPath/Internal/StringFunctions.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/XPath/Internal/StringFunctions.cs @@ -9,6 +9,7 @@ using System.Xml; using System.Xml.XPath; using System.Xml.Xsl; +using System.Xml.Xsl.Runtime; namespace MS.Internal.Xml.XPath { @@ -225,31 +226,7 @@ private string Normalize(XPathNodeIterator nodeIterator) Debug.Assert(nodeIterator!.Current != null); value = nodeIterator.Current.Value; } - int modifyPos = -1; - char[] chars = value.ToCharArray(); - bool firstSpace = false; // Start false to trim the beginning - - for (int comparePos = 0; comparePos < chars.Length; comparePos++) - { - if (!XmlCharType.IsWhiteSpace(chars[comparePos])) - { - firstSpace = true; - modifyPos++; - chars[modifyPos] = chars[comparePos]; - } - else if (firstSpace) - { - firstSpace = false; - modifyPos++; - chars[modifyPos] = ' '; - } - } - - // Trim end - if (modifyPos > -1 && chars[modifyPos] == ' ') - modifyPos--; - - return new string(chars, 0, modifyPos + 1); + return XsltFunctions.NormalizeSpace(value); } private string Translate(XPathNodeIterator nodeIterator) @@ -258,28 +235,7 @@ private string Translate(XPathNodeIterator nodeIterator) string value = _argList[0].Evaluate(nodeIterator).ToString()!; string mapFrom = _argList[1].Evaluate(nodeIterator).ToString()!; string mapTo = _argList[2].Evaluate(nodeIterator).ToString()!; - int modifyPos = -1; - char[] chars = value.ToCharArray(); - - for (int comparePos = 0; comparePos < chars.Length; comparePos++) - { - int index = mapFrom.IndexOf(chars[comparePos]); - if (index != -1) - { - if (index < mapTo.Length) - { - modifyPos++; - chars[modifyPos] = mapTo[index]; - } - } - else - { - modifyPos++; - chars[modifyPos] = chars[comparePos]; - } - } - - return new string(chars, 0, modifyPos + 1); + return XsltFunctions.Translate(value, mapFrom, mapTo); } public override XPathNodeIterator Clone() { return new StringFunctions(this); } diff --git a/src/libraries/System.Private.Xml/src/System/Xml/Xsl/Runtime/XsltFunctions.cs b/src/libraries/System.Private.Xml/src/System/Xml/Xsl/Runtime/XsltFunctions.cs index 84bed3581ce71c..c27c86f879ea4b 100644 --- a/src/libraries/System.Private.Xml/src/System/Xml/Xsl/Runtime/XsltFunctions.cs +++ b/src/libraries/System.Private.Xml/src/System/Xml/Xsl/Runtime/XsltFunctions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; @@ -21,6 +22,8 @@ namespace System.Xml.Xsl.Runtime [EditorBrowsable(EditorBrowsableState.Never)] public static class XsltFunctions { + private const int StackallocThreshold = 256; + //------------------------------------------------ // Xslt/XPath functions //------------------------------------------------ @@ -100,95 +103,79 @@ public static string Substring(string value, double startIndex, double length) public static string NormalizeSpace(string value) { - StringBuilder? sb = null; - int idx, idxStart = 0, idxSpace = 0; + char[]? rented = null; + Span chars = value.Length <= StackallocThreshold ? + stackalloc char[StackallocThreshold] : + (rented = ArrayPool.Shared.Rent(value.Length)); - for (idx = 0; idx < value.Length; idx++) + int length = 0; + bool firstSpace = false; // Start false to trim the beginning + foreach (char c in value) { - if (XmlCharType.IsWhiteSpace(value[idx])) + if (!XmlCharType.IsWhiteSpace(c)) + { + firstSpace = true; + chars[length++] = c; + } + else if (firstSpace) { - if (idx == idxStart) - { - // Previous character was a whitespace character, so discard this character - idxStart++; - } - else if (value[idx] != ' ' || idxSpace == idx) - { - // Space was previous character or this is a non-space character - if (sb == null) - { - sb = new StringBuilder(value.Length); - } - else - { - sb.Append(' '); - } - - // Copy non-space characters into string builder - sb.Append(value, idxStart, idxSpace == idx ? idx - idxStart - 1 : idx - idxStart); - - idxStart = idx + 1; - } - else - { - // Single whitespace character doesn't cause normalization, but mark its position - idxSpace = idx + 1; - } + firstSpace = false; + chars[length++] = ' '; } } - if (sb == null) + // Trim end + if (length > 0 && chars[length - 1] == ' ') { - // Check for string that is entirely composed of whitespace - if (idxStart == idx) return string.Empty; + length--; + } - // If string does not end with a space, then it must already be normalized - if (idxStart == 0 && idxSpace != idx) return value; + // If the string is already normalized, return it rather than allocating a copy + ReadOnlySpan normalized = chars.Slice(0, length); + string result = normalized.SequenceEqual(value) ? value : normalized.ToString(); - sb = new StringBuilder(value.Length); - } - else if (idx != idxStart) + if (rented != null) { - sb.Append(' '); + ArrayPool.Shared.Return(rented); } - - // Copy non-space characters into string builder - if (idxSpace == idx) - sb.Append(value, idxStart, idx - idxStart - 1); - else - sb.Append(value, idxStart, idx - idxStart); - - return sb.ToString(); + return result; } public static string Translate(string arg, string mapString, string transString) { - if (mapString.Length == 0) + // Skip the leading characters that are not mapped; if all of them are, the argument is returned unchanged + int i = 0; + while (i < arg.Length && mapString.IndexOf(arg[i]) < 0) + { + i++; + } + if (i == arg.Length) { return arg; } - StringBuilder sb = new StringBuilder(arg.Length); - - for (int i = 0; i < arg.Length; i++) + char[] chars = arg.ToCharArray(); + int length = i; + for (; i < chars.Length; i++) { - int index = mapString.IndexOf(arg[i]); + int index = mapString.IndexOf(chars[i]); if (index < 0) { // Keep the character - sb.Append(arg[i]); + chars[length++] = chars[i]; } else if (index < transString.Length) { // Replace the character - sb.Append(transString[index]); + chars[length++] = transString[index]; } else { // Remove the character } } - return sb.ToString(); + + return new string(chars, 0, length); } public static bool Lang(string value, XPathNavigator context)