From 03140d0ab0a7ceb309ee5b026307bde8b4c3bfac Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Sun, 26 Aug 2018 16:14:04 -0700 Subject: [PATCH 1/5] Protoype for nonallocating string formatting The goal here is to provide a mechanism for using interpolated strings ($"") without any unnecessary boxing and allocations for intrinsic types. The intent is also to provide a non-boxing format solution for the most common framework types, including Guid/TimeSpan and enums. --- corefxlab.sln | 30 ++ .../System.Text.ValueBuilder.csproj | 14 + .../System/FormatString.cs | 28 ++ .../System/IO/FormattingTextWriter.cs | 41 ++ .../System/Text/ValueStringBuilder.Format.cs | 332 +++++++++++++ .../System/Text/ValueStringBuilder.cs | 266 +++++++++++ .../System/Variant.cs | 444 ++++++++++++++++++ .../System/Variant2.cs | 21 + .../System/Variant3.cs | 23 + .../System/VariantExtensions.cs | 24 + .../System/VariantType.cs | 42 ++ tests/Benchmarks/Benchmarks.csproj | 1 + .../AppendBaseline.cs | 52 ++ .../System.Text.ValueBuilder/AppendVariant.cs | 45 ++ .../System.Text.ValueBuilder/Construction.cs | 29 ++ .../System.Text.ValueBuilder/GetValue.cs | 62 +++ .../MSBuildBaseline.cs | 194 ++++++++ .../MSBuildVariant.cs | 218 +++++++++ .../System.Text.ValueBuilder.Tests.csproj | 31 ++ .../ValueFormatTests.cs | 22 + .../VariantTests.cs | 129 +++++ 21 files changed, 2048 insertions(+) create mode 100644 src/System.Text.ValueBuilder/System.Text.ValueBuilder.csproj create mode 100644 src/System.Text.ValueBuilder/System/FormatString.cs create mode 100644 src/System.Text.ValueBuilder/System/IO/FormattingTextWriter.cs create mode 100644 src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs create mode 100644 src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs create mode 100644 src/System.Text.ValueBuilder/System/Variant.cs create mode 100644 src/System.Text.ValueBuilder/System/Variant2.cs create mode 100644 src/System.Text.ValueBuilder/System/Variant3.cs create mode 100644 src/System.Text.ValueBuilder/System/VariantExtensions.cs create mode 100644 src/System.Text.ValueBuilder/System/VariantType.cs create mode 100644 tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs create mode 100644 tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs create mode 100644 tests/Benchmarks/System.Text.ValueBuilder/Construction.cs create mode 100644 tests/Benchmarks/System.Text.ValueBuilder/GetValue.cs create mode 100644 tests/Benchmarks/System.Text.ValueBuilder/MSBuildBaseline.cs create mode 100644 tests/Benchmarks/System.Text.ValueBuilder/MSBuildVariant.cs create mode 100644 tests/System.Text.ValueBuilder.Tests/System.Text.ValueBuilder.Tests.csproj create mode 100644 tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs create mode 100644 tests/System.Text.ValueBuilder.Tests/VariantTests.cs diff --git a/corefxlab.sln b/corefxlab.sln index 25eadeeb231..c1b8b953669 100644 --- a/corefxlab.sln +++ b/corefxlab.sln @@ -98,6 +98,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Experimental.Coll EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Experimental.Collections.Tests", "tests\Microsoft.Experimental.Collections.Tests\Microsoft.Experimental.Collections.Tests.csproj", "{71D6F42F-95F7-486D-A659-22503A6D3D46}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.Text.ValueBuilder", "src\System.Text.ValueBuilder\System.Text.ValueBuilder.csproj", "{A679FE55-5C11-4CE2-A634-B78AC8C76C29}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.Text.ValueBuilder.Tests", "tests\System.Text.ValueBuilder.Tests\System.Text.ValueBuilder.Tests.csproj", "{20F82CD0-49EE-4A2A-945C-22BC34B136D3}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.Security.Cryptography.Asn1.Experimental", "src\System.Security.Cryptography.Asn1.Experimental\System.Security.Cryptography.Asn1.Experimental.csproj", "{D117AC96-F53F-47A8-81DF-F4A7CCEDB58E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.Security.Cryptography.Asn1.Experimental.Tests", "tests\System.Security.Cryptography.Asn1.Experimental.Tests\System.Security.Cryptography.Asn1.Experimental.Tests.csproj", "{C58BC591-9C3F-44E8-8408-7C57B1806B21}" @@ -616,6 +620,30 @@ Global {71D6F42F-95F7-486D-A659-22503A6D3D46}.Release|x64.Build.0 = Release|Any CPU {71D6F42F-95F7-486D-A659-22503A6D3D46}.Release|x86.ActiveCfg = Release|Any CPU {71D6F42F-95F7-486D-A659-22503A6D3D46}.Release|x86.Build.0 = Release|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Debug|x64.ActiveCfg = Debug|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Debug|x64.Build.0 = Debug|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Debug|x86.ActiveCfg = Debug|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Debug|x86.Build.0 = Debug|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Release|Any CPU.Build.0 = Release|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Release|x64.ActiveCfg = Release|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Release|x64.Build.0 = Release|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Release|x86.ActiveCfg = Release|Any CPU + {A679FE55-5C11-4CE2-A634-B78AC8C76C29}.Release|x86.Build.0 = Release|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Debug|x64.ActiveCfg = Debug|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Debug|x64.Build.0 = Debug|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Debug|x86.ActiveCfg = Debug|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Debug|x86.Build.0 = Debug|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Release|Any CPU.Build.0 = Release|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Release|x64.ActiveCfg = Release|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Release|x64.Build.0 = Release|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Release|x86.ActiveCfg = Release|Any CPU + {20F82CD0-49EE-4A2A-945C-22BC34B136D3}.Release|x86.Build.0 = Release|Any CPU {D117AC96-F53F-47A8-81DF-F4A7CCEDB58E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D117AC96-F53F-47A8-81DF-F4A7CCEDB58E}.Debug|Any CPU.Build.0 = Debug|Any CPU {D117AC96-F53F-47A8-81DF-F4A7CCEDB58E}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -687,6 +715,8 @@ Global {70873943-22E2-4254-9CE6-A0186586DCEC} = {3079E458-D0E6-4F99-8CAB-80011D35C7DA} {25E150ED-EAE6-4EB0-BA94-2FE25826EEDE} = {4B000021-5278-4F2A-B734-DE49F55D4024} {71D6F42F-95F7-486D-A659-22503A6D3D46} = {3079E458-D0E6-4F99-8CAB-80011D35C7DA} + {A679FE55-5C11-4CE2-A634-B78AC8C76C29} = {4B000021-5278-4F2A-B734-DE49F55D4024} + {20F82CD0-49EE-4A2A-945C-22BC34B136D3} = {3079E458-D0E6-4F99-8CAB-80011D35C7DA} {D117AC96-F53F-47A8-81DF-F4A7CCEDB58E} = {4B000021-5278-4F2A-B734-DE49F55D4024} {C58BC591-9C3F-44E8-8408-7C57B1806B21} = {3079E458-D0E6-4F99-8CAB-80011D35C7DA} EndGlobalSection diff --git a/src/System.Text.ValueBuilder/System.Text.ValueBuilder.csproj b/src/System.Text.ValueBuilder/System.Text.ValueBuilder.csproj new file mode 100644 index 00000000000..b8791af149e --- /dev/null +++ b/src/System.Text.ValueBuilder/System.Text.ValueBuilder.csproj @@ -0,0 +1,14 @@ + + + + Low allocation string building. + Microsoft Corporation, All rights reserved. + netcoreapp2.1 + true + .NET formatting parsing encoding UTF8 + + + + + + diff --git a/src/System.Text.ValueBuilder/System/FormatString.cs b/src/System.Text.ValueBuilder/System/FormatString.cs new file mode 100644 index 00000000000..0d2dd66fde9 --- /dev/null +++ b/src/System.Text.ValueBuilder/System/FormatString.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System +{ + /// + /// Simple wrapper for a format specifier string. We could potentially use this to indicate to C# that we have a direct + /// string formatting method. + /// + /// + /// for examples of how this would be used. + /// + public readonly ref struct FormatString + { + public ReadOnlySpan Format { get; } + + public FormatString(ReadOnlySpan format) + { + Format = format; + } + + public int Length => Format.Length; + + public static implicit operator FormatString(string format) => new FormatString(format); + public static implicit operator FormatString(ReadOnlySpan format) => new FormatString(format); + } +} diff --git a/src/System.Text.ValueBuilder/System/IO/FormattingTextWriter.cs b/src/System.Text.ValueBuilder/System/IO/FormattingTextWriter.cs new file mode 100644 index 00000000000..97421e6b249 --- /dev/null +++ b/src/System.Text.ValueBuilder/System/IO/FormattingTextWriter.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text; + +namespace System.IO +{ + public class FormattingTextWriter : TextWriter + { + private Encoding _encoding; + + public FormattingTextWriter(Encoding encoding) + { + _encoding = encoding; + } + + public override Encoding Encoding => _encoding; + + /// + /// If you were to call Write($"Some string {value}"), C# would see that we have a Write() that takes + /// and would prefer that. It would create a around + /// value and pass it via . + /// + /// Additionally, if you call Write($"Some string {value}", CultureInfo.CurrentCulture) it would get + /// transformed into a call to this specific method. General rules: + /// + /// 1. When invoking a method with an interpolated string... + /// 2. If there is an overload that matches with FormatString, ReadOnlySpan(Variant) in the $ position... + /// 3. Use it + /// + public void Write(FormatString format, ReadOnlySpan args, IFormatProvider formatProvider = null) + { + ValueStringBuilder vsb = new ValueStringBuilder(format.Length); + vsb.Append(format, args, formatProvider); + + Write(vsb.AsSpan()); + vsb.Dispose(); + } + } +} diff --git a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs new file mode 100644 index 00000000000..5151d6a9c9c --- /dev/null +++ b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs @@ -0,0 +1,332 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; + +namespace System.Text +{ + public ref partial struct ValueStringBuilder + { + // Undocumented exclusive limits on the range for Argument Hole Index and Argument Hole Alignment. + private const int IndexLimit = 1000000; // Note: 0 <= ArgIndex < IndexLimit + private const int WidthLimit = 1000000; // Note: -WidthLimit < ArgAlign < WidthLimit + + // This is a copy of the StringBuilder.AppendFormatHelper with minor functional tweaks: + // + // 1. Has a small stackalloc span for formatting Variant value types into. + // 2. Doesn't work with ISpanFormattable (the interface is currently internal). + // 3. Uses Variant to format with no allocations for value types. + // 4. Takes FormatString instead of string for format. + // 5. Takes ReadOnlySpan instead of ParamsArray. + // 6. Code formatting is scrubbed a bit for clarity. + + // Note that Argument Hole parsing can be factored into a helper, perhaps taking a callback + // delegate with (int index, int width, ReadOnlySpan itemFormat) or something along those + // lines. This would, of course, make the code a little slower, but the advantage of having + // shareable logic (between StringBuilder, ValueStringBuilder, etc.) may be worth it. + + public unsafe void Append(FormatString format, ReadOnlySpan args, IFormatProvider provider = null) + { + ReadOnlySpan formatSpan = format.Format; + + int position = 0; + int length = formatSpan.Length; + char current = '\x0'; + ValueStringBuilder unescapedItemFormat = default; + + // Can't do an inline stackalloc as we can't express that we won't capture the span. + char* c = stackalloc char[32]; + Span initialBuffer = new Span(c, 32); + ValueStringBuilder formatBuilder = new ValueStringBuilder(initialBuffer); + + ICustomFormatter customFormatter = null; + if (provider != null) + { + customFormatter = (ICustomFormatter)provider.GetFormat(typeof(ICustomFormatter)); + } + + while (true) + { + // Scan for an argument hole (braces) + while (position < length) + { + current = formatSpan[position]; + position++; + + if (current == '}') + { + if (position < length && formatSpan[position] == '}') + { + // Escaped brace (}}), skip + position++; + } + else + { + // Mismatched closing brace + FormatError(); + } + } + + if (current == '{') + { + if (position < length && formatSpan[position] == '{') + { + // Escaped brace ({{), skip + position++; + } + else + { + // Opening brace of an argument hole, fall out + position--; + break; + } + } + + // Plain text (or escaped brace). + Append(current); + } + + if (position == length) + { + // No arguments, exit + break; + } + + // + // Start of parsing of Argument Hole. + // Argument Hole ::= { Index (, WS* Alignment WS*)? (: Formatting)? } + // + + int index = 0; + + // Parse required Index parameter. + // Index ::= ('0'-'9')+ WS* + { + position++; + if (position == length || (current = formatSpan[position]) < '0' || current > '9') + { + // Need at least one digit + FormatError(); + } + + do + { + index = index * 10 + current - '0'; + position++; + if (position == length) + { + // End of text (can't have a closing brace) + FormatError(); + } + current = formatSpan[position]; + } while (current >= '0' && current <= '9' && index < IndexLimit); + + if (index >= args.Length) + { + throw new FormatException("Index (zero based) must be greater than or equal to zero and less than the size of the argument list."); + } + + // Consume optional whitespace. + while (position < length && (current = formatSpan[position]) == ' ') + { + position++; + } + } + + bool leftJustify = false; + int width = 0; + + // Parse optional Alignment + // Alignment ::= comma WS* minus? ('0'-'9')+ WS* + { + // Is the character a comma, which indicates the start of alignment parameter. + if (current == ',') + { + position++; + + // Consume optional whitespace + while (position < length && formatSpan[position] == ' ') + { + position++; + } + + if (position == length) + { + // End of text (can't have a closing brace) + FormatError(); + } + + current = formatSpan[position]; + if (current == '-') + { + // Minus sign means alignment is left justified. + leftJustify = true; + position++; + if (position == length) + { + // End of text (can't have a closing brace) + FormatError(); + } + current = formatSpan[position]; + } + + if (current < '0' || current > '9') + { + // Need at least one digit + FormatError(); + } + + do + { + width = width * 10 + current - '0'; + position++; + if (position == length) + { + // End of text (can't have a closing brace) + FormatError(); + } + + current = formatSpan[position]; + } + while (current >= '0' && current <= '9' && width < WidthLimit); + } + + // Consume optional whitespace + while (position < length && (current = formatSpan[position]) == ' ') + { + position++; + } + } + + ReadOnlySpan itemFormatSpan = default; + + // Parse optional formatting parameter. (colon) + if (current == ':') + { + position++; + int startPosition = position; + + while (true) + { + if (position == length) + { + // End of text (can't have a closing brace) + FormatError(); + } + current = formatSpan[position]; + position++; + + // Is character a opening or closing brace? + if (current == '}' || current == '{') + { + if (current == '{') + { + if (position < length && formatSpan[position] == '{') + { + // Escaped brace ({{), skip + position++; + } + else + { + // Error Argument Holes can not be nested. + FormatError(); + } + } + else + { + // Closing brace + + if (position < length && formatSpan[position] == '}') + { + // Escaped brace (}}), skip + position++; + } + else + { + // Closing brace of the argument hole. + position--; + break; + } + } + + // Reaching here means the brace has been escaped + // so we need to build up the format string in segments + unescapedItemFormat.Append(formatSpan.Slice(startPosition, position - startPosition - 1)); + startPosition = position; + } + } + + if (unescapedItemFormat.Length == 0) + { + if (startPosition != position) + { + // There was no brace escaping, extract the item format as a single string + itemFormatSpan = formatSpan.Slice(startPosition, position - startPosition); + } + } + else + { + unescapedItemFormat.Append(formatSpan.Slice(startPosition, position - startPosition)); + itemFormatSpan = unescapedItemFormat.ToString(); + unescapedItemFormat.Length = 0; + } + } + + if (current != '}') + { + // Missing closing argument brace + FormatError(); + } + + Variant arg = args[index]; + + // Construct the output for this argument hole. + position++; + ReadOnlySpan formattedSpan = default; + + if (customFormatter != null) + { + string itemFormat = null; + if (itemFormatSpan.Length != 0) + { + itemFormat = new string(itemFormatSpan); + } + formattedSpan = customFormatter.Format(itemFormat, arg.Box(), provider); + } + else + { + if (!arg.TryFormat(ref formatBuilder, itemFormatSpan, provider)) + { + Debug.Fail($"Failed to format index {index} with format span of '{new string(itemFormatSpan)}'"); + } + formattedSpan = formatBuilder.AsSpan(); + } + + int padding = width - formattedSpan.Length; + + if (!leftJustify && padding > 0) + { + Append(' ', padding); + } + + Append(formattedSpan); + + if (leftJustify && padding > 0) + { + Append(' ', padding); + } + + // Continue to parse other characters. + } + + unescapedItemFormat.Dispose(); + formatBuilder.Dispose(); + } + + private static void FormatError() + { + throw new FormatException("Input string was not in a correct format."); + } + } +} diff --git a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs new file mode 100644 index 00000000000..790479b8899 --- /dev/null +++ b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs @@ -0,0 +1,266 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace System.Text +{ + // *** This file is a simple copy from CoreFX/CoreCLR (made public) + + public ref partial struct ValueStringBuilder + { + private char[] _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder(Span initialBuffer) + { + _arrayToReturnToPool = null; + _chars = initialBuffer; + _pos = 0; + } + + public ValueStringBuilder(int initialCapacity) + { + _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length + { + get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _chars.Length); + _pos = value; + } + } + + public int Capacity => _chars.Length; + + public void EnsureCapacity(int capacity) + { + if (capacity > _chars.Length) + Grow(capacity - _chars.Length); + } + + /// + /// Get a pinnable reference to the builder. + /// + /// Ensures that the builder has a null char after + public ref char GetPinnableReference(bool terminate = false) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + return ref MemoryMarshal.GetReference(_chars); + } + + public ref char this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _chars[index]; + } + } + + public override string ToString() + { + var s = _chars.Slice(0, _pos).ToString(); + Dispose(); + return s; + } + + /// Returns the underlying storage of the builder. + public Span RawChars => _chars; + + /// + /// Returns a span around the contents of the builder. + /// + /// Ensures that the builder has a null char after + public ReadOnlySpan AsSpan(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + return _chars.Slice(0, _pos); + } + + public ReadOnlySpan AsSpan() => _chars.Slice(0, _pos); + public ReadOnlySpan AsSpan(int start) => _chars.Slice(start, _pos - start); + public ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length); + + public bool TryCopyTo(Span destination, out int charsWritten) + { + if (_chars.Slice(0, _pos).TryCopyTo(destination)) + { + charsWritten = _pos; + Dispose(); + return true; + } + else + { + charsWritten = 0; + Dispose(); + return false; + } + } + + public void Insert(int index, char value, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + _chars.Slice(index, count).Fill(value); + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char c) + { + int pos = _pos; + if ((uint)pos < (uint)_chars.Length) + { + _chars[pos] = c; + _pos = pos + 1; + } + else + { + GrowAndAppend(c); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string s) + { + int pos = _pos; + if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + _pos = pos + 1; + } + else + { + AppendSlow(s); + } + } + + private void AppendSlow(string s) + { + int pos = _pos; + if (pos > _chars.Length - s.Length) + { + Grow(s.Length); + } + + s.AsSpan().CopyTo(_chars.Slice(pos)); + _pos += s.Length; + } + + public void Append(char c, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + Span dst = _chars.Slice(_pos, count); + for (int i = 0; i < dst.Length; i++) + { + dst[i] = c; + } + _pos += count; + } + + public unsafe void Append(char* value, int length) + { + int pos = _pos; + if (pos > _chars.Length - length) + { + Grow(length); + } + + Span dst = _chars.Slice(_pos, length); + for (int i = 0; i < dst.Length; i++) + { + dst[i] = *value++; + } + _pos += length; + } + + public unsafe void Append(ReadOnlySpan value) + { + int pos = _pos; + if (pos > _chars.Length - value.Length) + { + Grow(value.Length); + } + + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + int origPos = _pos; + if (origPos > _chars.Length - length) + { + Grow(length); + } + + _pos = origPos + length; + return _chars.Slice(origPos, length); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowAndAppend(char c) + { + Grow(1); + Append(c); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int requiredAdditionalCapacity) + { + Debug.Assert(requiredAdditionalCapacity > 0); + + char[] poolArray = ArrayPool.Shared.Rent(Math.Max(_pos + requiredAdditionalCapacity, _chars.Length * 2)); + + _chars.CopyTo(poolArray); + + char[] toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + char[] toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + } +} diff --git a/src/System.Text.ValueBuilder/System/Variant.cs b/src/System.Text.ValueBuilder/System/Variant.cs new file mode 100644 index 00000000000..7bfd82a763a --- /dev/null +++ b/src/System.Text.ValueBuilder/System/Variant.cs @@ -0,0 +1,444 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +namespace System +{ + /// + /// is a wrapper that avoids boxing common value types. + /// + public readonly struct Variant + { + private readonly Union _union; + public readonly VariantType Type; + private readonly object _object; + + /// + /// Get the value as an object if the value is stored as an object. + /// + /// The value, if an object, or null. + /// True if the value is actually an object. + public bool TryGetValue(out object value) + { + bool isObject = Type == VariantType.Object; + value = isObject ? _object : null; + return isObject; + } + + /// + /// Get the value as the requested type if actually stored as that type. + /// + /// The value if stored as (T), or default. + /// True if the is of the requested type. + public unsafe bool TryGetValue(out T value) where T : unmanaged + { + value = default; + bool success = false; + + // Checking the type gets all of the non-relevant compares elided by the JIT + if((typeof(T) == typeof(bool) && Type == VariantType.Boolean) + || (typeof(T) == typeof(byte) && Type == VariantType.Byte) + || (typeof(T) == typeof(char) && Type == VariantType.Char) + || (typeof(T) == typeof(DateTime) && Type == VariantType.DateTime) + || (typeof(T) == typeof(DateTimeOffset) && Type == VariantType.DateTimeOffset) + || (typeof(T) == typeof(decimal) && Type == VariantType.Decimal) + || (typeof(T) == typeof(double) && Type == VariantType.Double) + || (typeof(T) == typeof(Guid) && Type == VariantType.Guid) + || (typeof(T) == typeof(short) && Type == VariantType.Int16) + || (typeof(T) == typeof(int) && Type == VariantType.Int32) + || (typeof(T) == typeof(long) && Type == VariantType.Int64) + || (typeof(T) == typeof(sbyte) && Type == VariantType.SByte) + || (typeof(T) == typeof(float) && Type == VariantType.Single) + || (typeof(T) == typeof(TimeSpan) && Type == VariantType.TimeSpan) + || (typeof(T) == typeof(ushort) && Type == VariantType.UInt16) + || (typeof(T) == typeof(uint) && Type == VariantType.UInt32) + || (typeof(T) == typeof(ulong) && Type == VariantType.UInt64)) + { + // The JIT is able to generate more efficient code when including the + // code for CastTo() directly. + fixed (void* u = &_union) + { + value = *(T*)u; + } + success = true; + } + + return success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private unsafe T CastTo() where T : unmanaged + { + fixed (void* u = &_union) + { + return *(T*)u; + } + } + + // We have explicit constructors for each of the supported types for performance + // and to restrict Variant to "safe" types. Allowing any struct that would fit + // into the Union would expose users to issues where bad struct state could cause + // hard failures like buffer overruns etc. + + // Setting this = default is a bit simpler than setting _object and _union to + // default and generates less assembly / faster construction. + + public Variant(bool value) + { + this = default; + _union.Boolean = value; + Type = VariantType.Boolean; + } + + public Variant(byte value) + { + this = default; + _union.Byte = value; + Type = VariantType.Byte; + } + + public Variant(sbyte value) + { + this = default; + _union.SByte = value; + Type = VariantType.SByte; + } + + public Variant(short value) + { + this = default; + _union.Int16 = value; + Type = VariantType.Int16; + } + + public Variant(ushort value) + { + this = default; + _union.UInt16 = value; + Type = VariantType.UInt16; + } + + public Variant(int value) + { + this = default; + _union.Int32 = value; + Type = VariantType.Int32; + } + + public Variant(uint value) + { + this = default; + _union.UInt32 = value; + Type = VariantType.UInt32; + } + + public Variant(long value) + { + this = default; + _union.Int64 = value; + Type = VariantType.Int64; + } + + public Variant(ulong value) + { + this = default; + _union.UInt64 = value; + Type = VariantType.UInt64; + } + + public Variant(float value) + { + this = default; + _union.Single = value; + Type = VariantType.Single; + } + + public Variant(double value) + { + this = default; + _union.Double = value; + Type = VariantType.Double; + } + + public Variant(decimal value) + { + this = default; + _union.Decimal = value; + Type = VariantType.Decimal; + } + + public Variant(DateTime value) + { + this = default; + _union.DateTime = value; + Type = VariantType.DateTime; + } + + public Variant(DateTimeOffset value) + { + this = default; + _union.DateTimeOffset = value; + Type = VariantType.DateTimeOffset; + } + + public Variant(Guid value) + { + this = default; + _union.Guid = value; + Type = VariantType.Guid; + } + + public Variant(object value) + { + this = default; + _object = value; + Type = VariantType.Object; + } + + // The Variant struct gets laid out as follows on x64: + // + // | 4 bytes | 4 bytes | 16 bytes | 8 bytes | + // |---------|---------|---------------------------------------|-------------------| + // | Type | Unused | Union | Object | + // + // Layout of the struct is automatic and cannot be modified via [StructLayout]. + // Alignment requirements force Variant to be a multiple of 8 bytes. We could + // shrink from 32 to 24 bytes by either dropping the 16 byte types (DateTimeOffset, + // Decimal, and Guid) or stashing the flags in the Union and leveraging flag objects + // for the types that exceed 8 bytes. (DateTimeOffset might fit in 12, need to test.) + // + // We could theoretically do sneaky things with unused bits in the object pointer, much + // like ATOMs in Window handles (lowest 64K values). Presumably that isn't doable + // without runtime support though (putting "bad" values in an object pointer)? + // + // We could also allow storing arbitrary "unmanaged" values that would fit into 16 bytes. + // In that case we could store the typeof(T) in the _object field. That probably is only + // particularly useful for something like enums. I think we can avoid boxing, would need + // to expose a static entry point for formatting on System.Enum. Something like: + // + // public static string Format(Type enumType, ulong value) + // { + // RuntimeType rtType = enumType as RuntimeType; + // if (rtType == null) + // throw new ArgumentException(SR.Arg_MustBeType, nameof(enumType)); + // + // if (!enumType.IsEnum) + // throw new ArgumentException(SR.Arg_MustBeEnum, nameof(enumType)); + // + // return Enum.InternalFormat(rtType, ulong) ?? ulong.ToString(); + // } + // + // That is the minbar- as the string values are cached it would be a positive. We can + // obviously do even better if we expose a TryFormat that takes an input span. There + // is a little bit more to that, but nothing serious. + + [StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)] + private struct Union + { + [FieldOffset(0)] public byte Byte; + [FieldOffset(0)] public sbyte SByte; + [FieldOffset(0)] public char Char; + [FieldOffset(0)] public bool Boolean; + [FieldOffset(0)] public short Int16; + [FieldOffset(0)] public ushort UInt16; + [FieldOffset(0)] public int Int32; + [FieldOffset(0)] public uint UInt32; + [FieldOffset(0)] public long Int64; + [FieldOffset(0)] public ulong UInt64; + [FieldOffset(0)] public DateTime DateTime; // 8 bytes (ulong) + [FieldOffset(0)] public DateTimeOffset DateTimeOffset; // 16 bytes (DateTime & short) + [FieldOffset(0)] public float Single; // 4 bytes + [FieldOffset(0)] public double Double; // 8 bytes + [FieldOffset(0)] public decimal Decimal; // 16 bytes (4 ints) + [FieldOffset(0)] public Guid Guid; // 16 bytes (int, 2 shorts, 8 bytes) + } + + /// + /// Get the value as an object, boxing if necessary. + /// + public object Box() + { + switch (Type) + { + case VariantType.Boolean: + return CastTo(); + case VariantType.Byte: + return CastTo(); + case VariantType.Char: + return CastTo(); + case VariantType.DateTime: + return CastTo(); + case VariantType.DateTimeOffset: + return CastTo(); + case VariantType.Decimal: + return CastTo(); + case VariantType.Double: + return CastTo(); + case VariantType.Guid: + return CastTo(); + case VariantType.Int16: + return CastTo(); + case VariantType.Int32: + return CastTo(); + case VariantType.Int64: + return CastTo(); + case VariantType.Object: + return _object; + case VariantType.SByte: + return CastTo(); + case VariantType.Single: + return CastTo(); + case VariantType.TimeSpan: + return CastTo(); + case VariantType.UInt16: + return CastTo(); + case VariantType.UInt32: + return CastTo(); + case VariantType.UInt64: + return CastTo(); + default: + throw new InvalidOperationException(); + } + } + + // Idea is that you can cast to whatever supported type you want if you're explicit. + // Worst case is you get default or nonsense values. + + public static explicit operator bool(in Variant variant) => variant.CastTo(); + public static explicit operator byte(in Variant variant) => variant.CastTo(); + public static explicit operator char(in Variant variant) => variant.CastTo(); + public static explicit operator DateTime(in Variant variant) => variant.CastTo(); + public static explicit operator DateTimeOffset(in Variant variant) => variant.CastTo(); + public static explicit operator decimal(in Variant variant) => variant.CastTo(); + public static explicit operator double(in Variant variant) => variant.CastTo(); + public static explicit operator Guid(in Variant variant) => variant.CastTo(); + public static explicit operator short(in Variant variant) => variant.CastTo(); + public static explicit operator int(in Variant variant) => variant.CastTo(); + public static explicit operator long(in Variant variant) => variant.CastTo(); + public static explicit operator sbyte(in Variant variant) => variant.CastTo(); + public static explicit operator float(in Variant variant) => variant.CastTo(); + public static explicit operator TimeSpan(in Variant variant) => variant.CastTo(); + public static explicit operator ushort(in Variant variant) => variant.CastTo(); + public static explicit operator uint(in Variant variant) => variant.CastTo(); + public static explicit operator ulong(in Variant variant) => variant.CastTo(); + + public static implicit operator Variant(bool value) => new Variant(value); + public static implicit operator Variant(byte value) => new Variant(value); + public static implicit operator Variant(char value) => new Variant(value); + public static implicit operator Variant(DateTime value) => new Variant(value); + public static implicit operator Variant(DateTimeOffset value) => new Variant(value); + public static implicit operator Variant(decimal value) => new Variant(value); + public static implicit operator Variant(double value) => new Variant(value); + public static implicit operator Variant(Guid value) => new Variant(value); + public static implicit operator Variant(short value) => new Variant(value); + public static implicit operator Variant(int value) => new Variant(value); + public static implicit operator Variant(long value) => new Variant(value); + public static implicit operator Variant(sbyte value) => new Variant(value); + public static implicit operator Variant(float value) => new Variant(value); + public static implicit operator Variant(TimeSpan value) => new Variant(value); + public static implicit operator Variant(ushort value) => new Variant(value); + public static implicit operator Variant(uint value) => new Variant(value); + public static implicit operator Variant(ulong value) => new Variant(value); + + // Common object types + public static implicit operator Variant(string value) => new Variant(value); + + public static Variant Create(in Variant variant) => variant; + public static Variant2 Create(in Variant first, in Variant second) => new Variant2(in first, in second); + public static Variant3 Create(in Variant first, in Variant second, in Variant third) => new Variant3(in first, in second, in third); + + /// + /// Try to format the variant into the given span. + /// + /// + /// TODO: If we can make ISpanFormattable public (which this signature matches) + /// we could format objects if they implemented said interface. + /// + public bool TryFormat(ref ValueStringBuilder destination, ReadOnlySpan format = default, IFormatProvider provider = null) + { + // TODO: This generates a a lot of assembly instructions (575). Is there a way to make this faster/smaller? + bool success = false; + int charsWritten = 0; + + switch (Type) + { + case VariantType.Boolean: + success = ((bool)this).TryFormat(destination.RawChars, out charsWritten); + break; + case VariantType.Byte: + success = ((byte)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Char: + success = true; + destination.RawChars[0] = (char)this; + charsWritten = 1; + break; + case VariantType.DateTime: + success = ((DateTime)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.DateTimeOffset: + success = ((DateTimeOffset)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Decimal: + success = ((decimal)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Double: + success = ((double)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Guid: + success = ((Guid)this).TryFormat(destination.RawChars, out charsWritten, format); + break; + case VariantType.Int16: + success = ((short)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Int32: + success = ((int)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Int64: + success = ((long)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.SByte: + success = ((sbyte)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Single: + success = ((float)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.TimeSpan: + success = ((TimeSpan)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.UInt16: + success = ((ushort)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.UInt32: + success = ((uint)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.UInt64: + success = ((ulong)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + break; + case VariantType.Object: + // ISpanFormattable isn't public- if accessible this should check that *first* + string s = null; + if (_object is IFormattable formattable) + { + s = formattable.ToString(new string(format), provider); + } + else if (_object != null) + { + s = _object.ToString(); + } + + destination.Append(s); + break; + } + + if (charsWritten != 0) + destination.Length = charsWritten; + + return success; + } + } +} diff --git a/src/System.Text.ValueBuilder/System/Variant2.cs b/src/System.Text.ValueBuilder/System/Variant2.cs new file mode 100644 index 00000000000..699b3ea2af1 --- /dev/null +++ b/src/System.Text.ValueBuilder/System/Variant2.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System +{ + /// + /// This is a pattern we can use to create stack based spans of . + /// + public readonly struct Variant2 + { + public readonly Variant First; + public readonly Variant Second; + + public Variant2(in Variant first, in Variant second) + { + First = first; + Second = second; + } + } +} diff --git a/src/System.Text.ValueBuilder/System/Variant3.cs b/src/System.Text.ValueBuilder/System/Variant3.cs new file mode 100644 index 00000000000..ea26bd67e0f --- /dev/null +++ b/src/System.Text.ValueBuilder/System/Variant3.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System +{ + /// + /// This is a pattern we can use to create stack based spans of . + /// + public readonly struct Variant3 + { + public readonly Variant First; + public readonly Variant Second; + public readonly Variant Third; + + public Variant3(in Variant first, in Variant second, in Variant third) + { + First = first; + Second = second; + Third = third; + } + } +} diff --git a/src/System.Text.ValueBuilder/System/VariantExtensions.cs b/src/System.Text.ValueBuilder/System/VariantExtensions.cs new file mode 100644 index 00000000000..821a937ebba --- /dev/null +++ b/src/System.Text.ValueBuilder/System/VariantExtensions.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace System +{ + public static class VariantExtensions + { + // These are here as extension methods to facilitate experimenting with making the structs + // not readonly and passing by ref "implicitly" (as opposed to in). + + public static unsafe ReadOnlySpan ToSpan(in this Variant variant) + => MemoryMarshal.CreateReadOnlySpan(ref Unsafe.AsRef(in variant), 1); + + public static ReadOnlySpan ToSpan(in this Variant2 variant) + => MemoryMarshal.CreateReadOnlySpan(ref Unsafe.AsRef(in variant.First), 2); + + public static ReadOnlySpan ToSpan(in this Variant3 variant) + => MemoryMarshal.CreateReadOnlySpan(ref Unsafe.AsRef(in variant.First), 3); + } +} diff --git a/src/System.Text.ValueBuilder/System/VariantType.cs b/src/System.Text.ValueBuilder/System/VariantType.cs new file mode 100644 index 00000000000..f9b38055a62 --- /dev/null +++ b/src/System.Text.ValueBuilder/System/VariantType.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace System +{ + public enum VariantType + { + Object, + Byte, + SByte, + Char, + Boolean, + Int16, + UInt16, + Int32, + UInt32, + Int64, + UInt64, + DateTime, + DateTimeOffset, + TimeSpan, + Single, + Double, + Decimal, + Guid + + // TODO: + // + // We can support arbitrary enums, see comments near the Union definition. + // + // We should also support Memory. This would require access to the internals + // so that we can save the object and the offset/index. (Memory is object/int/int). + // + // Supporting Span would require making Variant a ref struct and access to + // internals at the very least. It isn't clear if we could simply stick a ByReference + // in here or if there would be additional need for runtime changes. + // + // A significant drawback of making Variant a ref struct is that you would no longer be + // able to create Variant[] or Span. + } +} diff --git a/tests/Benchmarks/Benchmarks.csproj b/tests/Benchmarks/Benchmarks.csproj index 5fb21e03326..443631adcdd 100644 --- a/tests/Benchmarks/Benchmarks.csproj +++ b/tests/Benchmarks/Benchmarks.csproj @@ -55,6 +55,7 @@ + diff --git a/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs b/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs new file mode 100644 index 00000000000..ddd207f5d98 --- /dev/null +++ b/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; +using System.Text; + +namespace Benchmarks.System.Text.ValueBuilder +{ + [MemoryDiagnoser] + public class AppendBaseline + { + private static StringBuilder s_builder = new StringBuilder(100); + + [Benchmark(Baseline = true)] + public void AppendFormatInt() + { + StringBuilder sb = new StringBuilder(); + sb.AppendFormat("The answer is {0}", 42); + } + + [Benchmark] + public void AppendFormatInt_Cached() + { + s_builder.Clear(); + s_builder.AppendFormat("The answer is {0}", 42); + } + + [Benchmark] + public void AppendFormatInt_PreSize() + { + StringBuilder sb = new StringBuilder(100); + sb.AppendFormat("The answer is {0}", 42); + } + + [Benchmark] + public void AppendFormatInt_ToString() + { + StringBuilder sb = new StringBuilder(); + sb.AppendFormat("The answer is {0}", 42); + sb.ToString(); + } + + [Benchmark] + public void AppendFormatInt_ToString_Presize() + { + StringBuilder sb = new StringBuilder(100); + sb.AppendFormat("The answer is {0}", 42); + sb.ToString(); + } + } +} diff --git a/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs b/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs new file mode 100644 index 00000000000..4a66e0cfaa8 --- /dev/null +++ b/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; +using System; +using System.Text; + +namespace Benchmarks.System.Text.ValueBuilder +{ + //[MemoryDiagnoser] + [DisassemblyDiagnoser(printAsm: true, recursiveDepth: 2)] + // [MemoryDiagnoser] //, printSource: true + [ShortRunJob] + public class AppendVariant + { + [Benchmark] + public void AppendFormatInt() + { + Variant v = 42; + ValueStringBuilder sb = new ValueStringBuilder(); + sb.Append("The answer is {0}", v.ToSpan()); + sb.Dispose(); + } + + [Benchmark] + public void AppendFormatInt_PreSize() + { + Variant v = 42; + ValueStringBuilder sb = new ValueStringBuilder(); + sb.Append("The answer is {0}", v.ToSpan()); + sb.Dispose(); + } + + [Benchmark(Baseline = true)] + public unsafe void AppendFormatInt_Stack() + { + Variant v = 42; + char* c = stackalloc char[100]; + Span span = new Span(c, 100); + ValueStringBuilder sb = new ValueStringBuilder(span); + sb.Append("The answer is {0}", v.ToSpan()); + } + } +} diff --git a/tests/Benchmarks/System.Text.ValueBuilder/Construction.cs b/tests/Benchmarks/System.Text.ValueBuilder/Construction.cs new file mode 100644 index 00000000000..5f0cbeef3e6 --- /dev/null +++ b/tests/Benchmarks/System.Text.ValueBuilder/Construction.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; +using System; + +namespace Benchmarks.System.Text.ValueBuilder +{ + //[MemoryDiagnoser] + [DisassemblyDiagnoser(printAsm: true)] + [ShortRunJob] + public class Construct + { + [Benchmark] + public bool ConstructBool() + { + Variant v = true; + return v.Type == VariantType.Boolean; + } + + [Benchmark] + public bool ConstructObject() + { + Variant v = new Variant(new object()); + return v.Type == VariantType.Object; + } + } +} diff --git a/tests/Benchmarks/System.Text.ValueBuilder/GetValue.cs b/tests/Benchmarks/System.Text.ValueBuilder/GetValue.cs new file mode 100644 index 00000000000..11fcba891cc --- /dev/null +++ b/tests/Benchmarks/System.Text.ValueBuilder/GetValue.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; +using System; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Benchmarks.System.Text.ValueBuilder +{ + // [DisassemblyDiagnoser(printAsm: true)] + // [MemoryDiagnoser] //, printSource: true + // [ShortRunJob] + public class GetValue + { + private Variant _int = 42; + private readonly Variant _readonlyInt = 101; + private object _boxedInt = 42; + + [GlobalSetup] + public void GlobalSetup() + { + _boxedInt = 1945; + } + + [Benchmark] + public int TryGetInt() + { + _int.TryGetValue(out int value); + value++; + return value; + } + + [Benchmark] + public void TryGetInt_Readonly() + { + _readonlyInt.TryGetValue(out int value); + } + + [Benchmark] + public void TryGetInt_Fail() + { + _int.TryGetValue(out float value); + } + + [Benchmark] + public int UnboxInt() + { + int i = Unbox(_boxedInt); + i++; + return i; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static int Unbox(object o) + { + return (int)o; + } + } +} diff --git a/tests/Benchmarks/System.Text.ValueBuilder/MSBuildBaseline.cs b/tests/Benchmarks/System.Text.ValueBuilder/MSBuildBaseline.cs new file mode 100644 index 00000000000..976a9189607 --- /dev/null +++ b/tests/Benchmarks/System.Text.ValueBuilder/MSBuildBaseline.cs @@ -0,0 +1,194 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; +using System; +using System.Globalization; +using System.Text; + +namespace Benchmarks.System.Text.ValueBuilder +{ + [MemoryDiagnoser] + public class MSBuildBaseline + { + [Benchmark] + public void ExactLocationFormat() + { + string result = FormatEventMessage("error", "CS", "Missing ;", "312", "source.cs", 233, 236, 4, 8, 0); + } + + [Benchmark] + public void ExactLocationFormat_Loop() + { + for (int i = 0; i < 10000; i++) + { + string result = FormatEventMessage("error", "CS", "Missing ;", "312", "source.cs", 233, 236, 4, 8, 0); + } + } + + // From MSBuild sources https://github.com/Microsoft/msbuild/blob/e70a3159d64f9ed6ec3b60253ef863fa883a99b1/src/Shared/EventArgsFormatting.cs + internal static string FormatEventMessage + ( + string category, + string subcategory, + string message, + string code, + string file, + int lineNumber, + int endLineNumber, + int columnNumber, + int endColumnNumber, + int threadId + ) + { + return FormatEventMessage(category, subcategory, message, code, file, null, lineNumber, endLineNumber, columnNumber, endColumnNumber, threadId); + } + + internal static string FormatEventMessage + ( + string category, + string subcategory, + string message, + string code, + string file, + string projectFile, + int lineNumber, + int endLineNumber, + int columnNumber, + int endColumnNumber, + int threadId + ) + { + StringBuilder format = new StringBuilder(); + + // Uncomment these lines to show show the processor, if present. + /* + if (threadId != 0) + { + format.Append("{0}>"); + } + */ + + if ((file == null) || (file.Length == 0)) + { + format.Append("MSBUILD : "); // Should not be localized. + } + else + { + format.Append("{1}"); + + if (lineNumber == 0) + { + format.Append(" : "); + } + else + { + if (columnNumber == 0) + { + if (endLineNumber == 0) + { + format.Append("({2}): "); + } + else + { + format.Append("({2}-{7}): "); + } + } + else + { + if (endLineNumber == 0) + { + if (endColumnNumber == 0) + { + format.Append("({2},{3}): "); + } + else + { + format.Append("({2},{3}-{8}): "); + } + } + else + { + if (endColumnNumber == 0) + { + format.Append("({2}-{7},{3}): "); + } + else + { + format.Append("({2},{3},{7},{8}): "); + } + } + } + } + } + + if ((subcategory != null) && (subcategory.Length != 0)) + { + format.Append("{9} "); + } + + // The category as a string (should not be localized) + format.Append("{4} "); + + // Put a code in, if available and necessary. + if (code == null) + { + format.Append(": "); + } + else + { + format.Append("{5}: "); + } + + // Put the message in, if available. + if (message != null) + { + format.Append("{6}"); + } + + // If the project file was specified, tack that onto the very end. + if (projectFile != null && !String.Equals(projectFile, file)) + { + format.Append(" [{10}]"); + } + + // A null message is allowed and is to be treated as a blank line. + if (null == message) + { + message = String.Empty; + } + + string finalFormat = format.ToString(); + + // If there are multiple lines, show each line as a separate message. + string[] lines = SplitStringOnNewLines(message); + StringBuilder formattedMessage = new StringBuilder(); + + for (int i = 0; i < lines.Length; i++) + { + formattedMessage.Append(String.Format( + CultureInfo.CurrentCulture, finalFormat, + threadId, file, + lineNumber, columnNumber, category, code, + lines[i], endLineNumber, endColumnNumber, + subcategory, projectFile)); + + if (i < (lines.Length - 1)) + { + formattedMessage.AppendLine(); + } + } + + return formattedMessage.ToString(); + } + + private static string[] SplitStringOnNewLines(string s) + { + string[] subStrings = s.Split(s_newLines, StringSplitOptions.None); + return subStrings; + } + + private static readonly string[] s_newLines = { "\r\n", "\n" }; + } +} diff --git a/tests/Benchmarks/System.Text.ValueBuilder/MSBuildVariant.cs b/tests/Benchmarks/System.Text.ValueBuilder/MSBuildVariant.cs new file mode 100644 index 00000000000..8cb13b91b09 --- /dev/null +++ b/tests/Benchmarks/System.Text.ValueBuilder/MSBuildVariant.cs @@ -0,0 +1,218 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using BenchmarkDotNet.Attributes; +using System; +using System.Globalization; +using System.Text; + +namespace Benchmarks.System.Text.ValueBuilder +{ + [MemoryDiagnoser] + public class MSBuildVariant + { + [ThreadStatic] + private static Variant[] t_threadVariants = new Variant[11]; + + [Benchmark] + public void ExactLocationFormat() + { + string result = FormatEventMessage("error", "CS", "Missing ;", "312", "source.cs", 233, 236, 4, 8, 0); + } + + [Benchmark] + public void ExactLocationFormat_Loop() + { + for (int i = 0; i < 10000; i++) + { + string result = FormatEventMessage("error", "CS", "Missing ;", "312", "source.cs", 233, 236, 4, 8, 0); + } + } + + // From MSBuild sources https://github.com/Microsoft/msbuild/blob/e70a3159d64f9ed6ec3b60253ef863fa883a99b1/src/Shared/EventArgsFormatting.cs + internal static string FormatEventMessage + ( + string category, + string subcategory, + string message, + string code, + string file, + int lineNumber, + int endLineNumber, + int columnNumber, + int endColumnNumber, + int threadId + ) + { + return FormatEventMessage(category, subcategory, message, code, file, null, lineNumber, endLineNumber, columnNumber, endColumnNumber, threadId); + } + + unsafe internal static string FormatEventMessage + ( + string category, + string subcategory, + string message, + string code, + string file, + string projectFile, + int lineNumber, + int endLineNumber, + int columnNumber, + int endColumnNumber, + int threadId + ) + { + char* c = stackalloc char[72]; + ValueStringBuilder format = new ValueStringBuilder(new Span(c, 72)); + + // Uncomment these lines to show show the processor, if present. + /* + if (threadId != 0) + { + format.Append("{0}>"); + } + */ + + if ((file == null) || (file.Length == 0)) + { + format.Append("MSBUILD : "); // Should not be localized. + } + else + { + format.Append("{1}"); + + if (lineNumber == 0) + { + format.Append(" : "); + } + else + { + if (columnNumber == 0) + { + if (endLineNumber == 0) + { + format.Append("({2}): "); + } + else + { + format.Append("({2}-{7}): "); + } + } + else + { + if (endLineNumber == 0) + { + if (endColumnNumber == 0) + { + format.Append("({2},{3}): "); + } + else + { + format.Append("({2},{3}-{8}): "); + } + } + else + { + if (endColumnNumber == 0) + { + format.Append("({2}-{7},{3}): "); + } + else + { + format.Append("({2},{3},{7},{8}): "); + } + } + } + } + } + + if ((subcategory != null) && (subcategory.Length != 0)) + { + format.Append("{9} "); + } + + // The category as a string (should not be localized) + format.Append("{4} "); + + // Put a code in, if available and necessary. + if (code == null) + { + format.Append(": "); + } + else + { + format.Append("{5}: "); + } + + // Put the message in, if available. + if (message != null) + { + format.Append("{6}"); + } + + // If the project file was specified, tack that onto the very end. + if (projectFile != null && !String.Equals(projectFile, file)) + { + format.Append(" [{10}]"); + } + + // A null message is allowed and is to be treated as a blank line. + if (null == message) + { + message = String.Empty; + } + + char* f = stackalloc char[256]; + ValueStringBuilder formattedMessage = new ValueStringBuilder(new Span(f, 256)); + + Variant[] variants = t_threadVariants; + + variants[0] = threadId; + variants[1] = file; + variants[2] = lineNumber; + variants[3] = columnNumber; + variants[4] = category; + variants[5] = code; + variants[7] = endLineNumber; + variants[8] = endColumnNumber; + variants[9] = subcategory; + variants[10] = projectFile; + + // If there are multiple lines, show each line as a separate message. + + int index = message.IndexOf('\n'); + if (index < 0) + { + variants[6] = message; + formattedMessage.Append(format.AsSpan(), variants, CultureInfo.CurrentCulture); + } + else + { + string[] lines = SplitStringOnNewLines(message); + for (int i = 0; i < lines.Length; i++) + { + variants[6] = lines[i]; + formattedMessage.Append(format.AsSpan(), variants); + if (i < (lines.Length - 1)) + { + formattedMessage.Append(Environment.NewLine); + } + } + } + + format.Dispose(); + string result = formattedMessage.ToString(); + formattedMessage.Dispose(); + return result; + } + + private static string[] SplitStringOnNewLines(string s) + { + string[] subStrings = s.Split(s_newLines, StringSplitOptions.None); + return subStrings; + } + + private static readonly string[] s_newLines = { "\r\n", "\n" }; + } +} diff --git a/tests/System.Text.ValueBuilder.Tests/System.Text.ValueBuilder.Tests.csproj b/tests/System.Text.ValueBuilder.Tests/System.Text.ValueBuilder.Tests.csproj new file mode 100644 index 00000000000..ddab1254f0e --- /dev/null +++ b/tests/System.Text.ValueBuilder.Tests/System.Text.ValueBuilder.Tests.csproj @@ -0,0 +1,31 @@ + + + + netcoreapp2.1 + True + ../../tools/test_key.snk + true + true + + Microsoft Corporation, All rights reserved + + + + + + False + + + + + + + + + + + + + + + diff --git a/tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs b/tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs new file mode 100644 index 00000000000..0fc07e18bcf --- /dev/null +++ b/tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Xunit; + +namespace System.Text.ValueBuilder.Tests +{ + public class ValueFormatTests + { + [Theory, + InlineData(true, "The answer is {0}", "The answer is True"), + InlineData(true, "{0} is the answer", "False is the answer")] + public void FormatSingleBool(bool value, string format, string expected) + { + Variant variant = new Variant(value); + ValueStringBuilder vsb = new ValueStringBuilder(expected.Length); + vsb.Append(format, variant.ToSpan()); + string result = vsb.ToString(); + } + } +} diff --git a/tests/System.Text.ValueBuilder.Tests/VariantTests.cs b/tests/System.Text.ValueBuilder.Tests/VariantTests.cs new file mode 100644 index 00000000000..eced377cd2f --- /dev/null +++ b/tests/System.Text.ValueBuilder.Tests/VariantTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using Xunit; + +namespace System.Text.ValueBuilder.Tests +{ + public class VariantTests + { + [Fact] + public void Size() + { + Assert.Equal(32, Marshal.SizeOf()); + } + + [Theory, + InlineData(0), + InlineData(int.MaxValue), + InlineData(int.MinValue), + InlineData(42)] + public void IntStorage(int value) + { + Variant v = new Variant(value); + Assert.True(v.TryGetValue(out int result)); + Assert.Equal(value, result); + Assert.False(v.TryGetValue(out object _)); + Assert.Equal(value, (int)v); + var span = v.ToSpan(); + Assert.Equal(1, span.Length); + Assert.True(span[0].TryGetValue(out result)); + Assert.Equal(value, result); + } + + [Fact] + public void TwoVariant_HardCast() + { + var variant = Variant.Create(19, 42); + var span = variant.ToSpan(); + Assert.Equal(2, span.Length); + Assert.True(span[0].TryGetValue(out int result)); + Assert.Equal(19, result); + Assert.True(span[1].TryGetValue(out result)); + Assert.Equal(42, result); + } + + [Fact] + public void ThreeVariant_HardCast() + { + var variant = Variant.Create(1, 2, 3); + var span = variant.ToSpan(); + Assert.Equal(3, span.Length); + Assert.True(span[0].TryGetValue(out int result)); + Assert.Equal(1, result); + Assert.True(span[1].TryGetValue(out result)); + Assert.Equal(2, result); + Assert.True(span[2].TryGetValue(out result)); + Assert.Equal(3, result); + } + + [Fact] + public void TwoVariant_Variant2() + { + var variant = Variant.Create(19, 42); + var span = variant.ToSpan(); + Assert.Equal(2, span.Length); + Assert.True(span[0].TryGetValue(out int result)); + Assert.Equal(19, result); + Assert.True(span[1].TryGetValue(out result)); + Assert.Equal(42, result); + } + + [Theory, + InlineData(0), + InlineData(float.MaxValue), + InlineData(float.MinValue), + InlineData(4.2)] + public void FloatStorage(float value) + { + var variant = Variant.Create(value); + Assert.Equal(VariantType.Single, variant.Type); + Assert.True(variant.TryGetValue(out float result)); + Assert.Equal(value, result); + Assert.False(variant.TryGetValue(out object _)); + } + + [Fact] + public void GuidStorage() + { + Guid guid = Guid.NewGuid(); + var variant = Variant.Create(guid); + Assert.Equal(VariantType.Guid, variant.Type); + Assert.True(variant.TryGetValue(out Guid result)); + Assert.Equal(guid, result); + Assert.False(variant.TryGetValue(out object _)); + } + + [Theory, + InlineData(true), + InlineData(false)] + public void BooleanStorage(bool value) + { + Variant v = new Variant(value); + Assert.True(v.TryGetValue(out bool result)); + Assert.Equal(value, result); + Assert.False(v.TryGetValue(out object _)); + } + + public static TheoryData DecimalData => new TheoryData + { + 0, + decimal.MaxValue, + decimal.MinValue, + decimal.MinusOne + }; + + [Theory, + MemberData(nameof(DecimalData))] + public void DecimalStorage(decimal value) + { + var variant = Variant.Create(value); + Assert.Equal(VariantType.Decimal, variant.Type); + Assert.True(variant.TryGetValue(out decimal result)); + Assert.Equal(value, result); + Assert.False(variant.TryGetValue(out object _)); + } + } +} From 36b4ebb06aeb4caad1844ca027e821a7b57543df Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Tue, 20 Nov 2018 15:43:57 -0800 Subject: [PATCH 2/5] Remove marshal test --- tests/System.Text.ValueBuilder.Tests/VariantTests.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/System.Text.ValueBuilder.Tests/VariantTests.cs b/tests/System.Text.ValueBuilder.Tests/VariantTests.cs index eced377cd2f..2ab2c3891af 100644 --- a/tests/System.Text.ValueBuilder.Tests/VariantTests.cs +++ b/tests/System.Text.ValueBuilder.Tests/VariantTests.cs @@ -2,19 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Runtime.InteropServices; using Xunit; namespace System.Text.ValueBuilder.Tests { public class VariantTests { - [Fact] - public void Size() - { - Assert.Equal(32, Marshal.SizeOf()); - } - [Theory, InlineData(0), InlineData(int.MaxValue), From 69c6f4a547837f6e6b2b460859916b0d07823591 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Wed, 21 Nov 2018 15:37:27 -0800 Subject: [PATCH 3/5] Address feedback --- .../System/Text/ValueStringBuilder.cs | 2 +- src/System.Text.ValueBuilder/System/Variant.cs | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs index 790479b8899..4bc8b991e0e 100644 --- a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs +++ b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs @@ -203,7 +203,7 @@ public unsafe void Append(char* value, int length) _pos += length; } - public unsafe void Append(ReadOnlySpan value) + public void Append(ReadOnlySpan value) { int pos = _pos; if (pos > _chars.Length - value.Length) diff --git a/src/System.Text.ValueBuilder/System/Variant.cs b/src/System.Text.ValueBuilder/System/Variant.cs index 7bfd82a763a..3452670453b 100644 --- a/src/System.Text.ValueBuilder/System/Variant.cs +++ b/src/System.Text.ValueBuilder/System/Variant.cs @@ -58,12 +58,7 @@ public unsafe bool TryGetValue(out T value) where T : unmanaged || (typeof(T) == typeof(uint) && Type == VariantType.UInt32) || (typeof(T) == typeof(ulong) && Type == VariantType.UInt64)) { - // The JIT is able to generate more efficient code when including the - // code for CastTo() directly. - fixed (void* u = &_union) - { - value = *(T*)u; - } + value = CastTo(); success = true; } @@ -71,12 +66,9 @@ public unsafe bool TryGetValue(out T value) where T : unmanaged } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private unsafe T CastTo() where T : unmanaged + private T CastTo() where T : unmanaged { - fixed (void* u = &_union) - { - return *(T*)u; - } + return Unsafe.As(ref Unsafe.AsRef(_union)); } // We have explicit constructors for each of the supported types for performance From ca35e46f7e06bf25235514bdd1f674a2aae8fbb1 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Tue, 15 Jan 2019 18:27:24 -0800 Subject: [PATCH 4/5] Try giving VSB a default buffer. --- .../System/Text/ValueStringBuilder.cs | 13 ++++++++- .../AppendBaseline.cs | 9 +++++++ .../System.Text.ValueBuilder/AppendVariant.cs | 27 ++++++++++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs index 4bc8b991e0e..740e43bf767 100644 --- a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs +++ b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs @@ -11,11 +11,13 @@ namespace System.Text { // *** This file is a simple copy from CoreFX/CoreCLR (made public) - public ref partial struct ValueStringBuilder + public unsafe ref partial struct ValueStringBuilder { private char[] _arrayToReturnToPool; private Span _chars; private int _pos; + private fixed char _default[DefaultBufferSize]; + private const int DefaultBufferSize = 16; public ValueStringBuilder(Span initialBuffer) { @@ -240,6 +242,15 @@ private void Grow(int requiredAdditionalCapacity) { Debug.Assert(requiredAdditionalCapacity > 0); + if (_pos + requiredAdditionalCapacity <= DefaultBufferSize) + { + fixed (char* c = _default) + { + _chars = new Span(c, DefaultBufferSize); + } + return; + } + char[] poolArray = ArrayPool.Shared.Rent(Math.Max(_pos + requiredAdditionalCapacity, _chars.Length * 2)); _chars.CopyTo(poolArray); diff --git a/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs b/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs index ddd207f5d98..350b0a19831 100644 --- a/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs +++ b/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs @@ -8,6 +8,8 @@ namespace Benchmarks.System.Text.ValueBuilder { [MemoryDiagnoser] + // [DisassemblyDiagnoser(printAsm: true, recursiveDepth: 2)] + // [ShortRunJob] public class AppendBaseline { private static StringBuilder s_builder = new StringBuilder(100); @@ -19,6 +21,13 @@ public void AppendFormatInt() sb.AppendFormat("The answer is {0}", 42); } + [Benchmark] + public void AppendFormatIntString() + { + StringBuilder sb = new StringBuilder(); + sb.AppendFormat("The answer is {0}, the question is {1}", 42, "6 x 7"); + } + [Benchmark] public void AppendFormatInt_Cached() { diff --git a/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs b/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs index 4a66e0cfaa8..4ac15a2c086 100644 --- a/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs +++ b/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs @@ -3,15 +3,16 @@ // See the LICENSE file in the project root for more information. using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnostics.Windows.Configs; using System; using System.Text; namespace Benchmarks.System.Text.ValueBuilder { - //[MemoryDiagnoser] - [DisassemblyDiagnoser(printAsm: true, recursiveDepth: 2)] - // [MemoryDiagnoser] //, printSource: true - [ShortRunJob] + [MemoryDiagnoser] + // [EtwProfiler] + // [DisassemblyDiagnoser(printAsm: true, recursiveDepth: 2)] + // [ShortRunJob] public class AppendVariant { [Benchmark] @@ -23,6 +24,24 @@ public void AppendFormatInt() sb.Dispose(); } + [Benchmark] + public void AppendFormatIntString() + { + ValueStringBuilder sb = new ValueStringBuilder(); + sb.Append("The answer is {0}, the question is {1}", Variant.Create(42, "6 x 7").ToSpan()); + sb.Dispose(); + } + + [Benchmark] + public unsafe void AppendFormatIntString_Stack() + { + char* c = stackalloc char[100]; + Span span = new Span(c, 100); + ValueStringBuilder sb = new ValueStringBuilder(span); + sb.Append("The answer is {0}, the question is {1}", Variant.Create(42, "6 x 7").ToSpan()); + sb.Dispose(); + } + [Benchmark] public void AppendFormatInt_PreSize() { From cddd7cc4d2d0f971ec7c2ed81825e887e2b0827c Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Tue, 22 Jan 2019 22:57:33 -0800 Subject: [PATCH 5/5] Experimenting more with reducing copies --- .../System/Text/ValueStringBuilder.Format.cs | 92 +++++++++++-------- .../System/Text/ValueStringBuilder.cs | 77 ++++++++++++++-- .../System/Variant.cs | 91 ++++++++++-------- .../AppendBaseline.cs | 2 +- .../System.Text.ValueBuilder/AppendVariant.cs | 15 ++- .../HardCasts.cs | 20 ++++ .../ValueFormatTests.cs | 22 ++++- 7 files changed, 230 insertions(+), 89 deletions(-) create mode 100644 tests/System.Text.ValueBuilder.Tests/HardCasts.cs diff --git a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs index 5151d6a9c9c..a004ce585f2 100644 --- a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs +++ b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.Format.cs @@ -26,12 +26,12 @@ public ref partial struct ValueStringBuilder // lines. This would, of course, make the code a little slower, but the advantage of having // shareable logic (between StringBuilder, ValueStringBuilder, etc.) may be worth it. - public unsafe void Append(FormatString format, ReadOnlySpan args, IFormatProvider provider = null) + public unsafe void Append(FormatString formatString, ReadOnlySpan args, IFormatProvider provider = null) { - ReadOnlySpan formatSpan = format.Format; + ReadOnlySpan format = formatString.Format; int position = 0; - int length = formatSpan.Length; + int formatLength = format.Length; char current = '\x0'; ValueStringBuilder unescapedItemFormat = default; @@ -49,14 +49,14 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm while (true) { // Scan for an argument hole (braces) - while (position < length) + while (position < formatLength) { - current = formatSpan[position]; + current = format[position]; position++; if (current == '}') { - if (position < length && formatSpan[position] == '}') + if (position < formatLength && format[position] == '}') { // Escaped brace (}}), skip position++; @@ -70,7 +70,7 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm if (current == '{') { - if (position < length && formatSpan[position] == '{') + if (position < formatLength && format[position] == '{') { // Escaped brace ({{), skip position++; @@ -87,7 +87,7 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm Append(current); } - if (position == length) + if (position == formatLength) { // No arguments, exit break; @@ -104,7 +104,7 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm // Index ::= ('0'-'9')+ WS* { position++; - if (position == length || (current = formatSpan[position]) < '0' || current > '9') + if (position == formatLength || (current = format[position]) < '0' || current > '9') { // Need at least one digit FormatError(); @@ -114,12 +114,12 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm { index = index * 10 + current - '0'; position++; - if (position == length) + if (position == formatLength) { // End of text (can't have a closing brace) FormatError(); } - current = formatSpan[position]; + current = format[position]; } while (current >= '0' && current <= '9' && index < IndexLimit); if (index >= args.Length) @@ -128,7 +128,7 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm } // Consume optional whitespace. - while (position < length && (current = formatSpan[position]) == ' ') + while (position < formatLength && (current = format[position]) == ' ') { position++; } @@ -146,29 +146,29 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm position++; // Consume optional whitespace - while (position < length && formatSpan[position] == ' ') + while (position < formatLength && format[position] == ' ') { position++; } - if (position == length) + if (position == formatLength) { // End of text (can't have a closing brace) FormatError(); } - current = formatSpan[position]; + current = format[position]; if (current == '-') { // Minus sign means alignment is left justified. leftJustify = true; position++; - if (position == length) + if (position == formatLength) { // End of text (can't have a closing brace) FormatError(); } - current = formatSpan[position]; + current = format[position]; } if (current < '0' || current > '9') @@ -181,19 +181,19 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm { width = width * 10 + current - '0'; position++; - if (position == length) + if (position == formatLength) { // End of text (can't have a closing brace) FormatError(); } - current = formatSpan[position]; + current = format[position]; } while (current >= '0' && current <= '9' && width < WidthLimit); } // Consume optional whitespace - while (position < length && (current = formatSpan[position]) == ' ') + while (position < formatLength && (current = format[position]) == ' ') { position++; } @@ -209,20 +209,20 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm while (true) { - if (position == length) + if (position == formatLength) { - // End of text (can't have a closing brace) + // End of text (didn't find closing brace) FormatError(); } - current = formatSpan[position]; + current = format[position]; position++; - // Is character a opening or closing brace? + // Is character an opening or closing brace? if (current == '}' || current == '{') { if (current == '{') { - if (position < length && formatSpan[position] == '{') + if (position < formatLength && format[position] == '{') { // Escaped brace ({{), skip position++; @@ -237,7 +237,7 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm { // Closing brace - if (position < length && formatSpan[position] == '}') + if (position < formatLength && format[position] == '}') { // Escaped brace (}}), skip position++; @@ -252,7 +252,7 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm // Reaching here means the brace has been escaped // so we need to build up the format string in segments - unescapedItemFormat.Append(formatSpan.Slice(startPosition, position - startPosition - 1)); + unescapedItemFormat.Append(format.Slice(startPosition, position - startPosition - 1)); startPosition = position; } } @@ -262,13 +262,13 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm if (startPosition != position) { // There was no brace escaping, extract the item format as a single string - itemFormatSpan = formatSpan.Slice(startPosition, position - startPosition); + itemFormatSpan = format.Slice(startPosition, position - startPosition); } } else { - unescapedItemFormat.Append(formatSpan.Slice(startPosition, position - startPosition)); - itemFormatSpan = unescapedItemFormat.ToString(); + unescapedItemFormat.Append(format.Slice(startPosition, position - startPosition)); + itemFormatSpan = unescapedItemFormat.AsSpan(); unescapedItemFormat.Length = 0; } } @@ -283,34 +283,52 @@ public unsafe void Append(FormatString format, ReadOnlySpan args, IForm // Construct the output for this argument hole. position++; - ReadOnlySpan formattedSpan = default; + ReadOnlySpan formattedItem = default; + + // If we don't have a custom formatter and don't need to right justify we + // can create directly into the output buffer and avoid a copy. + bool canFormatDirect = customFormatter == null && (width == 0 || leftJustify); + int padding = 0; - if (customFormatter != null) + if (canFormatDirect) + { + int initialLength = Length; + if (!arg.TryFormat(ref this, itemFormatSpan, provider)) + { + Debug.Fail($"Failed to format index {index} with format span of '{new string(itemFormatSpan)}'"); + } + padding = width - Length - initialLength; + } + else if (customFormatter != null) { string itemFormat = null; if (itemFormatSpan.Length != 0) { itemFormat = new string(itemFormatSpan); } - formattedSpan = customFormatter.Format(itemFormat, arg.Box(), provider); + formattedItem = customFormatter.Format(itemFormat, arg.Box(), provider); + padding = width - formattedItem.Length; } else { + formatBuilder.Length = 0; if (!arg.TryFormat(ref formatBuilder, itemFormatSpan, provider)) { Debug.Fail($"Failed to format index {index} with format span of '{new string(itemFormatSpan)}'"); } - formattedSpan = formatBuilder.AsSpan(); + formattedItem = formatBuilder.AsSpan(); + padding = width - formattedItem.Length; } - int padding = width - formattedSpan.Length; - if (!leftJustify && padding > 0) { Append(' ', padding); } - Append(formattedSpan); + if (!canFormatDirect) + { + Append(formattedItem); + } if (leftJustify && padding > 0) { diff --git a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs index 740e43bf767..b5d3c661b69 100644 --- a/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs +++ b/src/System.Text.ValueBuilder/System/Text/ValueStringBuilder.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#define STACKBUFFER +#define TRACKRENT + using System.Buffers; using System.Diagnostics; using System.Runtime.CompilerServices; @@ -9,15 +12,41 @@ namespace System.Text { - // *** This file is a simple copy from CoreFX/CoreCLR (made public) + // This file is a copy from CoreFX/CoreCLR (made public) with simple tweaks. + // As much as possible code has been left the same to allow for easier comparison. public unsafe ref partial struct ValueStringBuilder { private char[] _arrayToReturnToPool; private Span _chars; private int _pos; + + // Stringbuilder has a default size of 16. This is an experiment to + // see the impact of reserving a small amount of stack space. + // + // It does make a noticeable difference over renting the smallest buffer + // (16 chars), but copying is *very* costly so the max buffer size tracking may + // be more impactful. Still gathering data. + +#if STACKBUFFER private fixed char _default[DefaultBufferSize]; private const int DefaultBufferSize = 16; +#endif + +#if TRACKRENT + // The initial rent we'll do- we'll grow and contract this based + // on our rental history when we dispose of the builder. + [ThreadStatic] + private static int t_InitialMinRent = 0; + + // The largest initial rent we'll do based on history. + private const int MaxInitialRent = 1024; + + // The rent size decrement if we don't use the entire buffer. ArrayPool bucketing + // (memory is given out in ^2 chunks) should allow us to gradually "age" down into + // a smaller bucket size should we repeatedly not use the full bucket. + private const int InitialRentDecrement = 64; +#endif public ValueStringBuilder(Span initialBuffer) { @@ -77,9 +106,7 @@ public ref char this[int index] public override string ToString() { - var s = _chars.Slice(0, _pos).ToString(); - Dispose(); - return s; + return _chars.Slice(0, _pos).ToString(); } /// Returns the underlying storage of the builder. @@ -242,7 +269,14 @@ private void Grow(int requiredAdditionalCapacity) { Debug.Assert(requiredAdditionalCapacity > 0); - if (_pos + requiredAdditionalCapacity <= DefaultBufferSize) + int requestedSize = _pos + requiredAdditionalCapacity; + +#if STACKBUFFER + if (requestedSize <= DefaultBufferSize +#if TRACKRENT + && t_InitialMinRent <= requestedSize +#endif + ) { fixed (char* c = _default) { @@ -250,10 +284,26 @@ private void Grow(int requiredAdditionalCapacity) } return; } +#endif + +#if TRACKRENT + // We'll always grab the last max rent if it's bigger on the assumption that we can + // avoid extra copying / multiple grows. The ArrayPool will eventually flush unused + // buffers and we'll decrement our max rent size if we don't utilize the full buffer. - char[] poolArray = ArrayPool.Shared.Rent(Math.Max(_pos + requiredAdditionalCapacity, _chars.Length * 2)); + // If doubling up is greater, we'll go for that, up to 4K. - _chars.CopyTo(poolArray); + int rentSize = Math.Max(Math.Max(requestedSize, t_InitialMinRent), Math.Min(_chars.Length * 2, 4096)); +#else + int rentSize = Math.Max(requestedSize, Math.Min(_chars.Length * 2, 4096)); +#endif + + // At least double the size + // TODO: This should be smarter about large buffers- we don't want to grow too much.) + char[] poolArray = ArrayPool.Shared.Rent(rentSize); + + if (_chars.Length > 0) + _chars.CopyTo(poolArray); char[] toReturn = _arrayToReturnToPool; _chars = _arrayToReturnToPool = poolArray; @@ -267,9 +317,22 @@ private void Grow(int requiredAdditionalCapacity) public void Dispose() { char[] toReturn = _arrayToReturnToPool; + int length = _pos; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again if (toReturn != null) { +#if TRACKRENT + // Remember our usage to hint our next rent + if (length > t_InitialMinRent) + { + t_InitialMinRent = Math.Min(length, MaxInitialRent); + } + else if (length < t_InitialMinRent && t_InitialMinRent > InitialRentDecrement) + { + t_InitialMinRent -= InitialRentDecrement; + } +#endif ArrayPool.Shared.Return(toReturn); } } diff --git a/src/System.Text.ValueBuilder/System/Variant.cs b/src/System.Text.ValueBuilder/System/Variant.cs index 3452670453b..cc2eb076ca3 100644 --- a/src/System.Text.ValueBuilder/System/Variant.cs +++ b/src/System.Text.ValueBuilder/System/Variant.cs @@ -344,91 +344,108 @@ public object Box() public static Variant3 Create(in Variant first, in Variant second, in Variant third) => new Variant3(in first, in second, in third); /// - /// Try to format the variant into the given span. + /// Try to format the variant into the given builder. /// /// /// TODO: If we can make ISpanFormattable public (which this signature matches) /// we could format objects if they implemented said interface. /// - public bool TryFormat(ref ValueStringBuilder destination, ReadOnlySpan format = default, IFormatProvider provider = null) + public unsafe bool TryFormat(ref ValueStringBuilder destination, ReadOnlySpan format = default, IFormatProvider provider = null) { // TODO: This generates a a lot of assembly instructions (575). Is there a way to make this faster/smaller? bool success = false; int charsWritten = 0; + if (Type == VariantType.Object) + { + // ISpanFormattable isn't public- if accessible this should check that *first* + string s = null; + if (_object is IFormattable formattable) + { + s = formattable.ToString(new string(format), provider); + } + else if (_object != null) + { + s = _object.ToString(); + } + + destination.Append(s); + return true; + } + + const int CapacityNeeded = 32; + bool hasCapacity = destination.Capacity - destination.Length >= CapacityNeeded; + char* c = stackalloc char[CapacityNeeded]; + Span targetSpan = hasCapacity ? destination.AppendSpan(CapacityNeeded) : new Span(c, CapacityNeeded); + switch (Type) { + case VariantType.Int32: + success = ((int)this).TryFormat(targetSpan, out charsWritten, format, provider); + break; case VariantType.Boolean: - success = ((bool)this).TryFormat(destination.RawChars, out charsWritten); + success = ((bool)this).TryFormat(targetSpan, out charsWritten); break; case VariantType.Byte: - success = ((byte)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((byte)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.Char: success = true; - destination.RawChars[0] = (char)this; + targetSpan[0] = (char)this; charsWritten = 1; break; case VariantType.DateTime: - success = ((DateTime)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((DateTime)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.DateTimeOffset: - success = ((DateTimeOffset)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((DateTimeOffset)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.Decimal: - success = ((decimal)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((decimal)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.Double: - success = ((double)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((double)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.Guid: - success = ((Guid)this).TryFormat(destination.RawChars, out charsWritten, format); + success = ((Guid)this).TryFormat(targetSpan, out charsWritten, format); break; case VariantType.Int16: - success = ((short)this).TryFormat(destination.RawChars, out charsWritten, format, provider); - break; - case VariantType.Int32: - success = ((int)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((short)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.Int64: - success = ((long)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((long)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.SByte: - success = ((sbyte)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((sbyte)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.Single: - success = ((float)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((float)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.TimeSpan: - success = ((TimeSpan)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((TimeSpan)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.UInt16: - success = ((ushort)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((ushort)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.UInt32: - success = ((uint)this).TryFormat(destination.RawChars, out charsWritten, format, provider); + success = ((uint)this).TryFormat(targetSpan, out charsWritten, format, provider); break; case VariantType.UInt64: - success = ((ulong)this).TryFormat(destination.RawChars, out charsWritten, format, provider); - break; - case VariantType.Object: - // ISpanFormattable isn't public- if accessible this should check that *first* - string s = null; - if (_object is IFormattable formattable) - { - s = formattable.ToString(new string(format), provider); - } - else if (_object != null) - { - s = _object.ToString(); - } - - destination.Append(s); + success = ((ulong)this).TryFormat(targetSpan, out charsWritten, format, provider); break; } - if (charsWritten != 0) - destination.Length = charsWritten; + if (hasCapacity) + { + if (charsWritten != 0) + { + destination.Length = destination.Length - CapacityNeeded + charsWritten; + } + } + else + { + destination.Append(targetSpan.Slice(0, charsWritten)); + } return success; } diff --git a/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs b/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs index 350b0a19831..8a03fe99329 100644 --- a/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs +++ b/tests/Benchmarks/System.Text.ValueBuilder/AppendBaseline.cs @@ -7,7 +7,7 @@ namespace Benchmarks.System.Text.ValueBuilder { - [MemoryDiagnoser] + // [MemoryDiagnoser] // [DisassemblyDiagnoser(printAsm: true, recursiveDepth: 2)] // [ShortRunJob] public class AppendBaseline diff --git a/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs b/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs index 4ac15a2c086..3943b01528a 100644 --- a/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs +++ b/tests/Benchmarks/System.Text.ValueBuilder/AppendVariant.cs @@ -9,8 +9,8 @@ namespace Benchmarks.System.Text.ValueBuilder { - [MemoryDiagnoser] - // [EtwProfiler] + // [MemoryDiagnoser] + // [EtwProfiler(performExtraBenchmarksRun: false)] // [DisassemblyDiagnoser(printAsm: true, recursiveDepth: 2)] // [ShortRunJob] public class AppendVariant @@ -32,6 +32,17 @@ public void AppendFormatIntString() sb.Dispose(); } + // [Benchmark] + public void AppendFormatIntString_Multiple() + { + for (int i = 0; i < 100; i++) + { + ValueStringBuilder sb = new ValueStringBuilder(); + sb.Append("The answer is {0}, the question is {1}", Variant.Create(42, "6 x 7").ToSpan()); + sb.Dispose(); + } + } + [Benchmark] public unsafe void AppendFormatIntString_Stack() { diff --git a/tests/System.Text.ValueBuilder.Tests/HardCasts.cs b/tests/System.Text.ValueBuilder.Tests/HardCasts.cs new file mode 100644 index 00000000000..23459e22b24 --- /dev/null +++ b/tests/System.Text.ValueBuilder.Tests/HardCasts.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; + +namespace System.Text.ValueBuilder.Tests +{ + public class HardCasts + { + [Fact] + public void Cast() + { + long longValue = (long)(Variant)(-1); + } + } +} diff --git a/tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs b/tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs index 0fc07e18bcf..41d47945bd6 100644 --- a/tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs +++ b/tests/System.Text.ValueBuilder.Tests/ValueFormatTests.cs @@ -9,14 +9,26 @@ namespace System.Text.ValueBuilder.Tests public class ValueFormatTests { [Theory, - InlineData(true, "The answer is {0}", "The answer is True"), - InlineData(true, "{0} is the answer", "False is the answer")] - public void FormatSingleBool(bool value, string format, string expected) + InlineData(true, "The answer is {0}"), + InlineData(false, "{0,10} is the answer"), + InlineData(true, "{0} is the answer")] + public void FormatSingleBool(bool value, string format) { Variant variant = new Variant(value); - ValueStringBuilder vsb = new ValueStringBuilder(expected.Length); + ValueStringBuilder vsb = new ValueStringBuilder(); vsb.Append(format, variant.ToSpan()); - string result = vsb.ToString(); + Assert.Equal(string.Format(format, value), vsb.ToString()); + vsb.Dispose(); + } + + [Theory, + InlineData(42, "6 x 7", "The answer is {0}, the question is {1}")] + public void FormatIntString(int value1, string value2, string format) + { + ValueStringBuilder vsb = new ValueStringBuilder(); + vsb.Append(format, Variant.Create(value1, value2).ToSpan()); + Assert.Equal(string.Format(format, value1, value2), vsb.ToString()); + vsb.Dispose(); } } }