Skip to content

Reusing StringBuilder with Object Pooling #965

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

VAllens
Copy link
Contributor

@VAllens VAllens commented May 6, 2025

Reuse StringBuilder using an object pool to reduce memory allocation.

I have an additional poll, would people be more inclined to introduce the Microsoft.Extensions.ObjectPool package?

Copy link
Member

@gbirchmeier gbirchmeier left a comment

Choose a reason for hiding this comment

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

@vyourtchenko ran a perf test, and we verified that this indeed offers performance gain, so that's nice.

My opinion is split on whether to add Microsoft.Extensions.ObjectPool to this project.

  • Pro: rely on the expertise of MS and get updates
  • Con: It would be QF/n's first outside dependency

Are ObjectPool and DefaultObjectPool adapted/copied from existing M.E.ObjectPool classes?


public void Dispose()
{
if (Builder.Length > 64 * 1024)
Copy link
Member

Choose a reason for hiding this comment

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

Why not clear a large StringBuilder?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why not clear a large StringBuilder?

Good question.

The idea here is to avoid returning unusually large StringBuilder instances to the pool. If we were to clear and return them, they would retain their large internal buffer, which could lead to excessive memory usage and potential LOH (Large Object Heap) allocations over time — especially if this happens frequently.

By skipping Return for builders over 64KB, we allow them to be garbage collected instead. This keeps the pool "lean", favoring smaller buffers that match typical usage patterns.

@@ -582,7 +583,8 @@ public int CalculateLength()
/// <returns></returns>
public virtual string CalculateString()
{
return CalculateString(new StringBuilder(), FieldOrder);
using PooledStringBuilder pooledSb = new PooledStringBuilder();
Copy link
Member

Choose a reason for hiding this comment

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

CalculateString gets called very often. Is there an unnecessary perf loss by creating a PooledStringBuilder every time this is called?

Thinking further.... would it be better to have a static singleton instance of PSB, instead of creating one (and disposing it) in every function call that uses it?

Copy link
Contributor Author

@VAllens VAllens May 20, 2025

Choose a reason for hiding this comment

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

CalculateString gets called very often. Is there an unnecessary perf loss by creating a PooledStringBuilder every time this is called?

Thinking further.... would it be better to have a static singleton instance of PSB, instead of creating one (and disposing it) in every function call that uses it?

That's a great observation — CalculateString() is indeed called frequently, and performance is important here.

A key detail here is that PooledStringBuilder is a struct, not a class — so its allocation happens on the stack, not the heap.
That means the wrapper itself is extremely lightweight and incurs no GC pressure.
Internally, it rents a StringBuilder from a shared pool, which avoids unnecessary allocations.

Using a static singleton PooledStringBuilder wouldn't be safe: since StringBuilder is mutable and not thread-safe, reusing a single instance across calls would introduce race conditions and potential data corruption.
Even with locking, the contention could outweigh any perceived benefit.

The current pattern strikes a good balance: fast, thread-safe, low-GC, and isolated across invocations.

@VAllens
Copy link
Contributor Author

VAllens commented May 20, 2025

@vyourtchenko ran a perf test, and we verified that this indeed offers performance gain, so that's nice.

My opinion is split on whether to add Microsoft.Extensions.ObjectPool to this project.

  • Pro: rely on the expertise of MS and get updates
  • Con: It would be QF/n's first outside dependency

Are ObjectPool and DefaultObjectPool adapted/copied from existing M.E.ObjectPool classes?

Nope, they’re not copied.
Our ObjectPool and DefaultObjectPool are quite minimal compared to the more sophisticated Microsoft.Extensions.ObjectPool.
Of course, most object pool implementations end up looking pretty similar anyway — the core idea doesn't vary that much.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants