Skip to content
This repository has been archived by the owner on Jan 23, 2023. It is now read-only.

Use ValueStringBuilder in preference to StringBuilderCache #26934

Closed

Conversation

benaadams
Copy link
Member

@benaadams benaadams commented Sep 28, 2019

Saves 44MB of allocations for the 20k Exceptions thrown async, caught, captured, .Throw(), caught and then throw https://gist.github.com/benaadams/49633777609ed91e8b41624b72026bf9

Before
image

After
image

@jkotas
Copy link
Member

jkotas commented Sep 28, 2019

We are trying to get rid of the existing usages of StringBuilderCache, not add more of them.

Also, the StringBuilderCache is 360 chars max, and the stacktrace is often going to bigger than that.

@jkotas
Copy link
Member

jkotas commented Sep 28, 2019

the StringBuilderCache is 360 chars max, and the stacktrace is often going to bigger than that.

And when it misses this limit, it will turn into Gen2 garbage that has worse side-effect than a bit of Gen0 garbage.

@benaadams
Copy link
Member Author

ValueStringBuilder instead then?

@EgorBo
Copy link
Member

EgorBo commented Sep 28, 2019

I wish "objects on stack" feature supported StringBuilder

@jkotas
Copy link
Member

jkotas commented Sep 28, 2019

Yes, ValueStringBuilder would be better.

@benaadams benaadams force-pushed the Exceptions-StringBuilderCache branch from 89c0a10 to d2be6bc Compare September 28, 2019 23:44
@benaadams benaadams changed the title Use StringBuilderCache for Exceptions Use ValueStringBuilder for Exceptions Sep 28, 2019
@benaadams
Copy link
Member Author

Had to add .AppendFormat to ValueStringBuilder not to have a different set of allocations popup

@benaadams
Copy link
Member Author

benaadams commented Sep 29, 2019

Updated summary:

Saves 44MB of allocations for the 20k Exceptions thrown async, caught, captured, .Throw(), caught and then throw https://gist.github.com/benaadams/49633777609ed91e8b41624b72026bf9

Before
image

After
image

@jkotas
Copy link
Member

jkotas commented Sep 29, 2019

Had to add .AppendFormat to ValueStringBuilder

That is a lot of code to add for saving allocations on exceptional paths. It does not feel worth it to me.

@benaadams
Copy link
Member Author

Also changed string.Format to use it rather than the StringBuilderCache and it should ease the removal of StringBuilderCache?

@jkotas
Copy link
Member

jkotas commented Sep 29, 2019

Also changed string.Format to use it rather than the StringBuilderCache

Ok, that is a convincing argument.

@benaadams benaadams changed the title Use ValueStringBuilder for Exceptions Use ValueStringBuilder in preference to StringBuilderCache Sep 29, 2019
@benaadams
Copy link
Member Author

I've also changed all of the less unusual uses of StringBuilderCache to use ValueStringBuilder now (not the ones that pass the cached StringBuilder around)

{
if (s is null) return;
Copy link
Member

Choose a reason for hiding this comment

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

Does enough places pass null into this to make this worth it?

Copy link
Member

Choose a reason for hiding this comment

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

(This is aggressive inlined everywhere.)

Copy link
Member Author

Choose a reason for hiding this comment

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

.ToString on object returning a string? doesn't help :) But no, putting the guards in the call sites.

Copy link
Member

Choose a reason for hiding this comment

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

.ToString on object returning a string? doesn't help :)

Other than in advanced implementations like Format using this, where is ToString being passed to Append on types that don't override it?

Copy link
Member Author

Choose a reason for hiding this comment

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

.ToString in JoinCore<T> on T; .ToString in JoinCore on object etc changes in b232228

Are basically on object or T

Copy link
Member

@stephentoub stephentoub Sep 29, 2019

Choose a reason for hiding this comment

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

Right, that's what I meant by advanced implementations, helpers in core types like string itself. And such methods need to check for null, anyway. We've had bugs filed in the past because sometimes they haven't, e.g.
https://github.com/dotnet/coreclr/issues/6785
dotnet/roslyn#34085
Whether it's the caller or callee checking, someone needs to check.

Copy link
Member

Choose a reason for hiding this comment

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

There is a lot of places that need the null check. I am thinking it would be better to just keep it inside the method. @benaadams @stephentoub Do you have an opinion?

Copy link
Member

@stephentoub stephentoub Sep 30, 2019

Choose a reason for hiding this comment

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

I'd prefer it inside:

  • Better matches StringBuilder
  • Easier to consume
  • Doesn't add a lot of complexity
  • Will lead to less IL (if not asm) due to number of call sites that needed the check

@@ -159,15 +159,16 @@ public static string Concat<T>(IEnumerable<T> values)

// Create the StringBuilder, add the chars we've already enumerated,
// add the rest, and then get the resulting string.
StringBuilder result = StringBuilderCache.Acquire();
Span<char> initialBuffer = stackalloc char[64];
Copy link
Member

Choose a reason for hiding this comment

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

The stack reserved sizes in this file can be probably be a bit higher, like 128 chars.

Copy link
Member

Choose a reason for hiding this comment

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

We've been fairly loose / non-prescriptive with how we do stackallocs like this in the past. In various places around corelib, we use various limits for stackalloc char when we don't know the actual upper limit, including 256, 128, 64, and 32 (lots of other sizes are used of course when we do have an upper bound). Not as part of this change, but it seems we should probably standardize with some const somewhere that everyone just uses.

Copy link
Member

Choose a reason for hiding this comment

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

I agree that a general reasonable sized buffer constant would be useful.

We often know the typical maximum size, e.g. 32-chars for a number formatting. I assume that we would keep these scenario specific maximum sizes.

Copy link
Member

Choose a reason for hiding this comment

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

I assume that we would keep these scenario specific maximum sizes.

Yup.

Copy link
Member

Choose a reason for hiding this comment

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

Might also want to harmonize with the jit's threshold for converting fixed sized locallocs to locals, which is currently 32. Back when I set this I did not see many > 32 cases.

Copy link
Member

@gfoidl gfoidl Sep 30, 2019

Choose a reason for hiding this comment

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

Here we have char, so that threshold would be 16 (= 32 bytes)?

@jkotas
Copy link
Member

jkotas commented Sep 30, 2019

cc @JeremyKuhne Is this change compatible with what we are planning around more efficient formatting?


do
{
currentValue = en.Current;

if (currentValue != null)
{
result.Append(currentValue.ToString());
string? str = currentValue.ToString();
Copy link
Member

Choose a reason for hiding this comment

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

There is a chance that the T supports ISpanFormattable. Calling the object overload should avoid the additional string allocation in that case.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, it should be done in separate change.

Copy link
Member

Choose a reason for hiding this comment

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

It's certainly possible, but I have to wonder how common it actually is. Calling Concat (not Join) with a bunch of integers, floating-point values, Guids, DateTimes, and TimeSpans is going to result in a bunch of gobbledeegook, as there won't be any separators between the values. Do you have an example where this would happen and be worth optimizing for? My gut would be to avoid the interface check as it actually succeeding would be super rare.

Copy link
Member

Choose a reason for hiding this comment

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

I'm thinking more of the future- in theory we'd want to make this interface public eventually, no?

Copy link
Member

@stephentoub stephentoub Oct 1, 2019

Choose a reason for hiding this comment

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

I'm thinking more of the future

Even then, what's an example where this would be valuable? It would need to be a T where performance is important and where TryFormat would include the necessary punctuation or spacing to make Concat a useful rendering.

In trying to find such examples (that might implement ISpanFormattable in the future), I'm not finding any... in fact I'm finding very little usage of this overload in general.

If you have such cases in mind, I'd be interested in seeing them.

Thanks.

Copy link
Member Author

Choose a reason for hiding this comment

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

It would need to be a T where performance is important ...

And then you probably wouldn't want to be using an IEnumerable<T> overload?

@JeremyKuhne
Copy link
Member

Is this change compatible with what we are planning around more efficient formatting?

Yes, this would.

cc: @jaredpar

@jaredpar
Copy link
Member

jaredpar commented Oct 1, 2019

Is this change compatible with what we are planning around more efficient formatting?

@jcouv

@@ -299,7 +303,8 @@ internal string ToString(TraceFormat traceFormat)
// Append original method name e.g. +MoveNext()
sb.Append('+');
sb.Append(methodName);
sb.Append('(').Append(')');
sb.Append('(');
sb.Append(')');
Copy link
Member

Choose a reason for hiding this comment

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

I haven't tested... is sb.Append('('); sb.Append(')'); significantly faster than sb.Append("()");, and if so, is it in the context of this method? Otherwise, I'd just assume using the simpler call.

if (_innerException != null)
{
innerException = _innerException.ToString();
}
Copy link
Member

Choose a reason for hiding this comment

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

string? innerException = _innerException?.ToString();

?

innerException = _innerException.ToString();
}

int initialLength = 128;
Copy link
Member

Choose a reason for hiding this comment

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

Where does this 128 come from? Would be worth a comment, even if it's just to say it's an arbitrary guess at the size required.

Copy link
Member

Choose a reason for hiding this comment

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

Might be clearer to just have + 128 when calling the constructor.

.Append(' ');
sb.Append(((IFormattable)this.GetElement(i)).ToString("G", invariant));
sb.Append(',');
sb.Append(' ');
Copy link
Member

Choose a reason for hiding this comment

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

Same question/comment as earlier... I wonder if sb.Append(", "); would be better, just for simplicity reasons.

@@ -646,17 +667,41 @@ public static string Join(string? separator, IEnumerable<string?> values)
}

// Null separator and values are handled by the StringBuilder
Copy link
Member

Choose a reason for hiding this comment

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

Per the other conversation we should probably change Append to accept nulls again. But if we don't, this comment will need to be updated.

result.Append(str);
}
}
while (en.MoveNext());
Copy link
Member

Choose a reason for hiding this comment

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

I assume this duplication can be reverted once Append is changed to allow null? (Even if it's not, I think it's worth avoiding the duplication and just having the explicit null check on each append for the separator.)

if (provider != null)
{
cf = (ICustomFormatter?)provider.GetFormat(typeof(ICustomFormatter));
}
Copy link
Member

@stephentoub stephentoub Oct 1, 2019

Choose a reason for hiding this comment

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

var cf = (ICustomFormatter?)provider?.GetFormat(typeof(ICustomFormatter));

?


int pos = 0;
int len = format.Length;
char ch = '\x0';
Copy link
Member

Choose a reason for hiding this comment

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

Nit: '\0'?

Copy link
Member Author

Choose a reason for hiding this comment

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

break;
}
}
// If it neither then treat the character as just text.
Copy link
Member

Choose a reason for hiding this comment

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

Nit: "If it neither" => "If it's neither"
Also it'd be nice if there were a blank line above this.

}
}
// Is it a opening brace?
if (ch == '{')
Copy link
Member

Choose a reason for hiding this comment

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

else if?

// Index ::= ('0'-'9')+ WS*
//
pos++;
// If reached end of text then error (Unexpected end of text)
Copy link
Member

Choose a reason for hiding this comment

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

Nit: blank line above this

ch = format[pos];

pos++;
// Is it a closing brace?
Copy link
Member

Choose a reason for hiding this comment

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

Nit: blank line above this

//
// Start of parsing of Argument Hole.
// Argument Hole ::= { Index (, WS* Alignment WS*)? (: Formatting)? }
//
Copy link
Member

Choose a reason for hiding this comment

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

Nit: can you remove the //s followed by nothing?

{
FormatError();
}
ch = format[pos];
Copy link
Member

Choose a reason for hiding this comment

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

Nit: blank line above this

{
FormatError();
}
// Parse alignment digits.
Copy link
Member

Choose a reason for hiding this comment

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

Nit: blank line above this

{
width = width * 10 + ch - '0';
pos++;
// If reached end of text then error. (Unexpected end of text)
Copy link
Member

Choose a reason for hiding this comment

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

Nit: blank line above this. I'll stop commenting on these.

}
}

private static void FormatError()
Copy link
Member

Choose a reason for hiding this comment

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

Nit: "FormatError" => "ThrowFormatException"

@@ -822,6 +822,7 @@
<Compile Include="$(MSBuildThisFileDirectory)System\Text\UTF7Encoding.cs" />
<Compile Include="$(MSBuildThisFileDirectory)System\Text\UTF8Encoding.cs" />
<Compile Include="$(MSBuildThisFileDirectory)System\Text\ValueStringBuilder.cs" />
<Compile Include="$(MSBuildThisFileDirectory)System\Text\ValueStringBuilder.AppendFormat.cs" />
Copy link
Member

Choose a reason for hiding this comment

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

Can we remove StringBuilderCache.cs from corelib as well, or are there still other uses?

Copy link
Member Author

Choose a reason for hiding this comment

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

There's still a few other uses; they are more problematic ones as one method gets the cached string builder, then it returns it, passes to other methods, then releases it later. Didn't tackle them as it would over-complicate the review.

Copy link
Member

@stephentoub stephentoub left a comment

Choose a reason for hiding this comment

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

Thanks for working on this.

@stephentoub
Copy link
Member

Replaced by #27441

@benaadams benaadams deleted the Exceptions-StringBuilderCache branch October 25, 2019 17:00
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants