Skip to content

StringBuilder: use Span.Fill in Append repeating char - #86287

Merged
adamsitnik merged 5 commits into
dotnet:mainfrom
yesmey:fill-append
Jul 7, 2023
Merged

StringBuilder: use Span.Fill in Append repeating char#86287
adamsitnik merged 5 commits into
dotnet:mainfrom
yesmey:fill-append

Conversation

@yesmey

@yesmeyyesmey commented May 16, 2023

Copy link
Copy Markdown
Contributor

StringBuilder.Append(char value, int repeatCount) currently use a loop to add each character in sequence.
We can instead use Span<T>.Fill that is better optimized for this.

Note:
Append(char value) was calling this method as a fallback to when it needed to allocate. But with the new changes, Append(char value, int repeatCount) might be inlined, therefor I added a specific AppendWithExpansion(char) with MethodImplOptions.NoInlining.

Benchmark code
Benchmark result:

BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.1778/22H2/2022Update/SunValley2)
AMD Ryzen 7 3700X, 1 CPU, 16 logical and 8 physical cores
.NET SDK=8.0.100-preview.6.23276.3
[Host] : .NET 8.0.0 (8.0.23.27214), X64 RyuJIT AVX2
Job-WZCNRH : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2
Job-RFXJAL : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2
MethodToolchainamountcMeanRatio
AppendRepeatedmain1a2.169 ns1.00
AppendRepeatedPR1a2.623 ns1.21
AppendRepeatedmain2a4.057 ns1.00
AppendRepeatedPR2a2.577 ns0.64
AppendRepeatedmain4a7.857 ns1.00
AppendRepeatedPR4a3.035 ns0.39
AppendRepeatedmain8a15.549 ns1.00
AppendRepeatedPR8a3.694 ns0.24
AppendRepeatedmain16a31.002 ns1.00
AppendRepeatedPR16a3.979 ns0.13

@ghostghost added needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners community-contribution Indicates that the PR has been added by a community member labels May 16, 2023
@vcsjonesvcsjones added area-System.Runtime and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels May 16, 2023
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @dotnet/area-system-runtime
See info in area-owners.md if you want to be subscribed.

Issue Details

StringBuilder.Append(char value, int repeatCount) currently use a loop to add each character in sequence.
We can instead use Span<T>.Fill that is better optimized for this.

Note:
Append(char value) was calling this method as a fallback to when it needed to allocate. But with the new changes, Append(char value, int repeatCount) might be inlined, therefor I added a specific AppendWithExpansion(char) with MethodImplOptions.NoInlining.

Benchmark code
Benchmark result:

BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.1702/22H2/2022Update/SunValley2)
AMD Ryzen 7 3700X, 1 CPU, 16 logical and 8 physical cores
.NET SDK=8.0.100-preview.5.23255.2
[Host] : .NET 8.0.0 (8.0.23.25213), X64 RyuJIT AVX2
Job-FJHCLO : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2
Job-DXTBEB : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2
MethodToolchainamountcMeanRatio
AppendRepeatedConstAmountmain??8.393 ns1.00
AppendRepeatedConstAmountPR??3.210 ns0.38
AppendRepeatedConstmain1?3.488 ns1.00
AppendRepeatedConstPR1?2.884 ns0.83
AppendRepeatedConstmain2?5.465 ns1.00
AppendRepeatedConstPR2?2.733 ns0.50
AppendRepeatedConstmain4?8.640 ns1.00
AppendRepeatedConstPR4?3.410 ns0.39
AppendRepeatedConstmain8?15.649 ns1.00
AppendRepeatedConstPR8?3.849 ns0.25
AppendRepeatedConstmain16?30.945 ns1.00
AppendRepeatedConstPR16?4.028 ns0.13
AppendRepeatedmain1a3.491 ns1.00
AppendRepeatedPR1a4.069 ns1.17
AppendRepeatedmain2a5.345 ns1.00
AppendRepeatedPR2a3.730 ns0.70
AppendRepeatedmain4a8.662 ns1.00
AppendRepeatedPR4a4.304 ns0.50
AppendRepeatedmain8a14.983 ns1.00
AppendRepeatedPR8a4.807 ns0.32
AppendRepeatedmain16a29.593 ns1.00
AppendRepeatedPR16a5.170 ns0.17
Author:yesmey
Assignees:-
Labels:

area-System.Runtime, community-contribution

Milestone:-

int firstLength = chunkChars.Length - chunkLength;
if (firstLength > 0)
{
chunkChars.AsSpan(chunkLength, firstLength).Fill(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Insert

Debug.Assert(firstLength<repeatCount,"We shouldn't be called if there was enough space for the entire run.");

Comment threadsrc/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs Outdated

@adamsitnikadamsitnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thank you @yesmey not just for the contribution but also for providing benchmark numbers and their source code!

Would you be interested in contributing a benchmark for Append(char, int) to https://github.com/dotnet/performance/blob/main/src/benchmarks/micro/libraries/System.Text/Perf.StringBuilder.cs? Currently we have no benchmarks for it and our reporting system won't report any improvements once I merge your PR.

Comment threadsrc/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs Outdated
@adamsitnikadamsitnik self-assigned this Jul 7, 2023
@adamsitnikadamsitnik added the tenet-performance Performance related issue label Jul 7, 2023
@adamsitnik
adamsitnik merged commit ff57624 into dotnet:mainJul 7, 2023
@adamsitnikadamsitnik added this to the 8.0.0 milestone Jul 7, 2023
@yesmey

yesmey commented Jul 12, 2023

Copy link
Copy Markdown
ContributorAuthor

@adamsitnik The regression in #88673 comes from marking the allocating path of Append(char) with MethodImplOptions.NoInlining. That benchmark in particular is allocating fairly often, and it makes sense the slow path is faster when it previously was being inlined more aggressively. This pattern is fairly common across the runtime, but if you prefer I can remove it and see if its better?

@adamsitnik

Copy link
Copy Markdown
Member

I can remove it and see if its better?

@yesmey could you please give it a try?

I was also thinking about doing sth like this:

publicStringBuilderAppend(charvalue){intnextCharIndex=m_ChunkLength;char[]chars=m_ChunkChars;if((uint)chars.Length==(uint)nextCharIndex){ExpandByABlock(1);nextCharIndex=0;chars=m_ChunkChars;}chars[nextCharIndex]=value;m_ChunkLength++;returnthis;}

but I am not sure if array boundaries check would got removed.

@MichalPetryka

Copy link
Copy Markdown
Contributor

but I am not sure if array boundaries check would got removed.

You can avoid them like this:

Unsafe.Add(refMemoryMarshal.GetArrayDataReference(chars),(uint)nextCharIndex)= value;

@yesmey

yesmey commented Jul 23, 2023

Copy link
Copy Markdown
ContributorAuthor

Sorry about the delay. I'm gonna need some help on this one.

Having trouble reproducing this locally. I think its because most of the benchmark is spent allocating and the Error column is sometimes very high. I tried to turn off as much as possible on my machine while running the benchmarks.

but I am not sure if array boundaries check would got removed.

You can avoid them like this:

Unsafe.Add(refMemoryMarshal.GetArrayDataReference(chars),(uint)nextCharIndex)= value;

I doubt avoiding an array boundaries check would even be noticeable here since the allocation size doubles every time, so the times that code is executed is gonna be rare, and sacrificing bounds safety for that is likely not worth it

My hypothesis is that the slowdown is coming from the benchmark calling this very frequently in a loop, and the jit is able to inline so the read/write of m_ChunkLength better in the previous version.

Here's how I build and run the benchmark locally

.\build.cmd Clr+Clr.Aot+Libs -c Release -rc Release

and

python3 .\scripts\benchmarks_ci.py -f net8.0 --filter System.Text.Tests.Perf_StringBuilder.Append_Char --corerun "D:\dotnet\runtime\artifacts\bin\testhost\net8.0-windows-Release-x64\shared\Microsoft.NETCore.App\8.0.0\CoreRun.exe""D:\dotnet\yesmey_runtime\artifacts\bin\testhost\net8.0-windows-Release-x64\shared\Microsoft.NETCore.App\8.0.0\CoreRun.exe" --bdn-artifacts C:\results\after_test

\yesmey_runtime...CoreRun.exe built from ff57624
\runtime...CoreRun.exe built from 5a03596 (parent)

BenchmarkDotNet=v0.13.2.2052-nightly, OS=Windows 11 (10.0.22621.1992)
AMD Ryzen 7 7800X3D, 1 CPU, 16 logical and 8 physical cores
.NET SDK=8.0.100-preview.7.23364.32
[Host] : .NET 8.0.0 (8.0.23.36403), X64 RyuJIT AVX2
Job-DWSUBF : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2
Job-YNMWPS : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2
PowerPlanMode=00000000-0000-0000-0000-000000000000 Arguments=/p:EnableUnsafeBinaryFormatterSerialization=true IterationTime=250.0000 ms MaxIterationCount=20 MinIterationCount=15 WarmupCount=1 
MethodJobToolchainlengthMeanErrorStdDevMedianMinMaxRatioGen0Gen1AllocatedAlloc Ratio
Append_CharJob-DWSUBF\runtime\artifacts\bin\testhost\net8.0-windows-Release-x64\shared\Microsoft.NETCore.App\8.0.0\CoreRun.exe10090.69 ns0.659 ns0.584 ns90.50 ns89.93 ns91.68 ns1.000.0108-544 B1.00
Append_CharJob-YNMWPS\yesmey_runtime\artifacts\bin\testhost\net8.0-windows-Release-x64\shared\Microsoft.NETCore.App\8.0.0\CoreRun.exe10097.78 ns0.240 ns0.201 ns97.72 ns97.61 ns98.18 ns1.080.0106-544 B1.00
Append_CharJob-DWSUBF\runtime\artifacts\bin\testhost\net8.0-windows-Release-x64\shared\Microsoft.NETCore.App\8.0.0\CoreRun.exe10000051,838.73 ns170.468 ns142.348 ns51,837.67 ns51,624.53 ns52,088.30 ns1.004.12543.5066209968 B1.00
Append_CharJob-YNMWPS\yesmey_runtime\artifacts\bin\testhost\net8.0-windows-Release-x64\shared\Microsoft.NETCore.App\8.0.0\CoreRun.exe10000051,924.36 ns184.830 ns163.847 ns51,939.05 ns51,721.98 ns52,278.99 ns1.004.13913.5182209968 B1.00

Here's the asm output:

Before

; System.Text.Tests.Perf_StringBuilder.Append_Char(Int32)pushrdipushrsipushrbppushrbxsubrsp,28movebx,edxmovrcx,offset MT_System.Text.StringBuildercall CORINFO_HELP_NEWSFASTmovrsi,raxmov dword ptr [rsi+20],7FFFFFFFmovrcx,offset MT_System.Char[]movedx,10call CORINFO_HELP_NEWARR_1_VClearcx,[rsi+8]movrdx,raxcall CORINFO_HELP_ASSIGN_REFxoredi,editestebx,ebxjle short M00_L02M00_L00:movecx,[rsi+18]movedx,ecxmovrax,[rsi+8]movr8d,[rax+8]cmpr8d,edxjbe short M00_L03movedx,edxmov word ptr [rax+rdx*2+10],61incecxmov[rsi+18],ecxM00_L01:incedicmpedi,ebxjl short M00_L00M00_L02:movrax,rsiaddrsp,28poprbxpoprbppoprsipoprdiretM00_L03:movebp,1movedx,[rsi+1C]leaedx,[rdx+rcx+1]cmpedx,[rsi+20]jg short M00_L07testedx,edxjle short M00_L07M00_L04:movrdx,[rsi+8]cmp[rdx+8],ecxjle short M00_L05leaeax,[rcx+1]cmpecx,[rdx+8]jae short M00_L08movecx,ecxmov word ptr [rdx+rcx*2+10],61decebpjmp short M00_L06M00_L05:mov[rsi+18],ecxmovrcx,rsimovedx,ebpcall qword ptr [7FFE1853D518]; System.Text.StringBuilder.ExpandByABlock(Int32)xorecx,ecxmoveax,ecxM00_L06:testebp,ebpmovecx,eaxjg short M00_L04mov[rsi+18],ecxjmp short M00_L01M00_L07:movrcx,offset MT_System.ArgumentOutOfRangeExceptioncall CORINFO_HELP_NEWSFASTmovrbx,raxmovecx,18331movrdx,7FFE180A4000call CORINFO_HELP_STRCNSmovrsi,raxcall qword ptr [7FFE18786790]movr8,raxmovrdx,rsimovrcx,rbxcall qword ptr [7FFE182B6F28]movrcx,rbxcall CORINFO_HELP_THROWint3M00_L08:call CORINFO_HELP_RNGCHKFAILint3; Total bytes of code 280

After

; System.Text.Tests.Perf_StringBuilder.Append_Char(Int32)pushrdipushrsipushrbxsubrsp,20movebx,edxmovrcx,offset MT_System.Text.StringBuildercall CORINFO_HELP_NEWSFASTmovrsi,raxmov dword ptr [rsi+20],7FFFFFFFmovrcx,offset MT_System.Char[]movedx,10call CORINFO_HELP_NEWARR_1_VClearcx,[rsi+8]movrdx,raxcall CORINFO_HELP_ASSIGN_REFxoredi,editestebx,ebxjle short M00_L02M00_L00:movecx,[rsi+18]movedx,ecxmovrax,[rsi+8]movr8d,[rax+8]cmpr8d,edxjbe short M00_L03movedx,edxmov word ptr [rax+rdx*2+10],61incecxmov[rsi+18],ecxM00_L01:incedicmpedi,ebxjl short M00_L00M00_L02:movrax,rsiaddrsp,20poprbxpoprsipoprdiretM00_L03:movrcx,rsimovedx,61call qword ptr [7FFE1853C3F0]; System.Text.StringBuilder.AppendWithExpansion(Char)jmp short M00_L01; Total bytes of code 137
; System.Text.StringBuilder.AppendWithExpansion(Char)pushrsipushrbxsubrsp,28movrbx,rcxmovesi,edxmovrcx,rbxmovedx,1call qword ptr [7FFE1853CB58]; System.Text.StringBuilder.ExpandByABlock(Int32)movrax,[rbx+8]cmp dword ptr [rax+8],0jbe short M01_L00mov[rax+10],siinc dword ptr [rbx+18]addrsp,28poprbxpoprsiretM01_L00:call CORINFO_HELP_RNGCHKFAILint3; Total bytes of code 55

@ghostghost locked as resolved and limited conversation to collaborators Aug 22, 2023
@yesmey
yesmey deleted the fill-append branch August 24, 2023 20:08
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.Runtimecommunity-contributionIndicates that the PR has been added by a community membertenet-performancePerformance related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@yesmey@adamsitnik@MichalPetryka@EgorBo@IDisposable@xtqqczze@vcsjones