Skip to content

Add IMemoryPoolFactory and cleanup memory pool while idle #61554

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 13 commits into
base: main
Choose a base branch
from

Conversation

BrennanConroy
Copy link
Member

@BrennanConroy BrennanConroy commented Apr 18, 2025

Adding the ability to release (to the GC) memory from the PinnedBlockMemoryPool. It does this by counting the rent and return calls and using heuristics to decide if it should free up memory or not based on pool usage.

Additionally implements the newly approved IMemoryPoolFactory<T> interface and plumbs it through Kestrel, IIS, and HttpSys with the default implementation for IMemoryPoolFactory<byte> being the PinnedBlockMemoryPool.

Kestrel makes use of its heartbeat loop to clean up the memory pools, whereas IIS and HttpSys don't have the equivalent so they use a PeriodicTimer approach.

Adds metrics to the pool. Issue for those is at #61594.

Closes #61003
Closes #27394
Closes #55490

Did a few perf runs to see the impact of Interlocked.Increment on the Rent and Return paths.
Did longer 60 second runs to make sure the memory pools had plenty of opportunity to run clean up logic and see the impact of incrementing counters.

  • A few runs of Plaintext Platform for 60 seconds had no noticeable impact (this is our 24m RPS scenario so it should be hitting the pool a lot)
  • A few runs of MvcJsonOutput60k for 60 seconds had no noticeable impact (wanted a benchmark that wrote data to the response)
  • A few runs of Plaintext Platform with 2 IOQueues (default is # cores/2) to make more requests use the same pool, this one might have a tiny impact on RPS, but there seemed to be a bit of noise, was getting 5.6m and 5.7m with 2 queues and 5.8m and 6m without PR, but it seemed noisy.

TLDR; didn't notice a perf impact with this PR and so removed the ScalableApproximateCounting code for now. Can always add it back if we find problems.

@BrennanConroy BrennanConroy added feature-iis Includes: IIS, ANCM feature-kestrel feature-httpsys area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions labels Apr 18, 2025
@adityamandaleeka adityamandaleeka linked an issue May 2, 2025 that may be closed by this pull request
@BrennanConroy BrennanConroy marked this pull request as ready for review May 15, 2025 01:13
private readonly Counter<long> _evictedMemory;
private readonly Counter<long> _usageRate;

public PinnedBlockMemoryPoolMetrics(IMeterFactory meterFactory)
Copy link
Member

Choose a reason for hiding this comment

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

Do we want a metric for peak?

Copy link
Member Author

@BrennanConroy BrennanConroy May 15, 2025

Choose a reason for hiding this comment

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

total_allocated covers that? Oh, nvm. Yeah, peak might be interesting.


private long _currentMemory;
private long _evictedMemory;
private DateTimeOffset _nextEviction = DateTime.UtcNow.AddSeconds(10);
Copy link
Member

Choose a reason for hiding this comment

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

Are we going to regret not making this configurable?

{
}

public PinnedBlockMemoryPool(IMeterFactory meterFactory)
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should pass an options object so we can easily add future options

PerformEviction();
}

internal void PerformEviction()
Copy link
Member

Choose a reason for hiding this comment

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

Can you add a comment that explains how this works? Use AI to generate it 😄


internal sealed class PinnedBlockMemoryPoolMetrics
{
// Note: Dot separated instead of dash.
Copy link
Member

Choose a reason for hiding this comment

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

Is that uncommon?

@JamesNK

Copy link
Member

@JamesNK JamesNK May 15, 2025

Choose a reason for hiding this comment

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

No, it's not uncommon for a meter name to use dots. All of our other meter names used. I think at one point the kestrel event source used dashes, but that's very old. Brennan might have copied that comment from there.

Comment doesn't seem nessessary


internal sealed class PinnedBlockMemoryPoolMetrics
{
// Note: Dot separated instead of dash.
Copy link
Member

@JamesNK JamesNK May 15, 2025

Choose a reason for hiding this comment

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

No, it's not uncommon for a meter name to use dots. All of our other meter names used. I think at one point the kestrel event source used dashes, but that's very old. Brennan might have copied that comment from there.

Comment doesn't seem nessessary

internal sealed class PinnedBlockMemoryPoolMetrics
{
// Note: Dot separated instead of dash.
public const string MeterName = "System.Buffers.PinnedBlockMemoryPool";
Copy link
Member

@JamesNK JamesNK May 15, 2025

Choose a reason for hiding this comment

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

I'm not sure if these metrics should be on a new meter. Couldn't they be on a meter the same name as Kestrel's meter?

A new meter name means folks would need to configure OTEL to export the kestrel meter, and this System.Buffers.PinnedBlockMemoryPool meter. Having memory stats related to kestrel be on the Kestrel meter makes more sense to me.

Copy link
Member

@JamesNK JamesNK May 15, 2025

Choose a reason for hiding this comment

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

I see there are multiple frameworks that use System.Buffers.PinnedBlockMemoryPool. Maybe it does make sense for it to be its own meter. Although if you have multiple frameworks using the pool at the same time then numbers will get merged together. Is that would you intend?

A solution would be to have an owner tag that says who used the memory. When a memory pool is created the owner could pass in their name, e.g. kestrel, sockets, iis, etc, and the name is internally passed to counters when they're changed. An owner tag would let you view total memory usage, vs just Kestrel HTTP layer, vs sockets layer, vs IIS (if running together in the same process)

Copy link
Member

Choose a reason for hiding this comment

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

I think the meter should be renamed to indicate that it's specific to ASP.NET Core. Maybe Microsoft.AspNetCore.MemoryPool? That is simple and obvious what the meter is for.

I don't think people care that the underlying pool is implemented using pinned blocks.

_meter = meterFactory.Create(MeterName);

_currentMemory = _meter.CreateUpDownCounter<long>(
"pinnedblockmemorypool.current_memory",
Copy link
Member

@JamesNK JamesNK May 15, 2025

Choose a reason for hiding this comment

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

This name should probably be something like aspnetcore.memorypool.current_memory.

Use aspnetcore.memorypool.* as the prefix for counter names.

Comment on lines +42 to +46
_usageRate = _meter.CreateCounter<long>(
"pinnedblockmemorypool.usage_rate",
unit: "{bytes}",
description: "Rate of bytes rented from the pool."
);
Copy link
Member

Choose a reason for hiding this comment

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

How is this a rate?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's what counters are. It just depends how you view the data.

https://learn.microsoft.com/dotnet/core/diagnostics/metrics-instrumentation#create-a-custom-metric

For Counter instruments, the convention is that collection tools show the total count and/or the rate at which the count is increasing

Copy link
Member

Choose a reason for hiding this comment

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

The runtime doesn't control whether it will be viewed as a rate so the counter name and description shouldn't presume that it will be. I'd suggest naming it based on the thing that is actually being counted. An example similar to this for total bytes allocated from the GC: RuntimeMetrics.cs


public PinnedBlockMemoryPoolMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create(MeterName);
Copy link
Member

@JamesNK JamesNK May 15, 2025

Choose a reason for hiding this comment

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

It looks like there could be multiple instances of memory pool used across assemblies with shared source. Fortunatly I believe that as long as the same IMeterFactory is used for all of them (injected by DI), the meter and counters created will be the same instances. Metrics values will be aggregated and saved to the same location.

However, I think a unit test to double check that is important.

@JamesNK
Copy link
Member

JamesNK commented May 15, 2025

@lmolkova @noahfalk More metrics are being added to ASP.NET Core. If you have time, your feedback on naming and metrics usage would be valuable.

@JamesNK
Copy link
Member

JamesNK commented May 15, 2025

@BrennanConroy FYI naming guidelines for OTEL are here: https://opentelemetry.io/docs/specs/semconv/general/naming/

@davidfowl
Copy link
Member

Here’s a review of the updated PinnedBlockMemoryPool from @dotnet/aspnetcore/pull/61554:


Key Changes and Features

  • Idle Cleanup/Eviction:
    The pool now supports scheduled eviction of idle blocks using an adaptive algorithm based on recent rent/return activity.
  • Eviction Scheduling:
    • TryScheduleEviction schedules an eviction every 10 seconds if needed.
    • Uses ThreadPool.UnsafeQueueUserWorkItem for eviction work.
  • Eviction Algorithm (PerformEviction):
    • If activity is trending down, removes a percentage of blocks proportional to excess returns.
    • If activity is steady, removes 1% of the current pool (at least 1).
    • If no activity, removes 5% (at least 10) for aggressive idle trimming.
  • Metrics:
    • Tracks metrics for memory usage, evictions, allocations, etc.
  • Dispose Pattern:
    • Thread-safe with a lock and an optional disposal callback.
  • Rent/Return:
    • Rent increments a counter, returns blocks to the pool unless disposed.
    • Rent over block size throws.
    • Double-increments rent count on allocation to bias the eviction algorithm.

Strengths

  • Adaptive Trimming:
    The eviction logic is sophisticated and adapts to changing patterns of usage, which is excellent for minimizing memory waste and avoiding thrashing.
  • Thread-Safe:
    Uses ConcurrentQueue and Interlocked for all counters and state, as well as a lock for Dispose.
  • Metrics:
    Well-instrumented, which will help diagnose issues and tune performance.
  • Activity-Aware:
    The algorithm takes recent rent/return activity into account, reducing the likelihood of evicting blocks that are needed soon.
  • Clear Ownership Model:
    Internal comments and method signatures make it clear who owns buffers and how they must be returned.

Potential Issues / Suggestions

  1. Eviction Calculation Granularity

    • When the pool is very small (say, less than 100 blocks), currentCount / 100 can be zero, so no blocks are evicted in “steady” or “trending less” activity. However, the Math.Max(1, ...) ensures at least 1 is evicted in the steady case.
  2. Eviction Timer Drift

    • _nextEviction = now.AddSeconds(10);
      If eviction is delayed (ThreadPool busy), evictions may drift, but this is typical for such background cleanup and not a problem for a memory pool.
  3. Dispose Race/Redundancy

    • The lock in Dispose(bool) is correct, but some state (_isDisposed) is set before the lock, which could allow a thread to see a partial dispose.
    • This risk is minor and would only affect metrics or rare edge cases. Consider moving _isDisposed = true; inside the lock for maximum safety.
  4. Metrics Double-Counting

    • The logic for incrementing _rentCount twice on allocation is intentional (to reduce eviction under high allocation), but could slightly skew metrics for external consumers. This is a fair tradeoff for the intended behavior.
  5. No Age-Based Eviction

    • All eviction is based on activity, not block idle age. This is typical and generally fine, but if age-based trimming is needed (e.g., only trim oldest), another queue or timestamps would be required.
  6. Block Finalizer/Leak Recovery

    • The comments reference finalizer-based leak recovery, but this is not visible in this excerpt—assume the MemoryPoolBlock handles this as before.
  7. Eviction During High Concurrency

    • If eviction is scheduled during a burst of high concurrency, there could be brief over-trimming. The activity-based scheme should minimize this risk, but monitoring is recommended in real workloads.

Summary Table

Aspect Status Notes
Thread Safety Uses concurrent structures and locking as needed
Adaptive Eviction Algorithm considers activity and trims efficiently
Metrics/Diagnostics Good instrumentation
Defensive Coding Careful checks on sizes, disposal, and ownership
Over/Under-trimming ⚠️/✅ Well-tuned, but as with all heuristics, workload-dependent
Disposal Robustness Locking and callback support

Conclusion

This is a robust, production-grade adaptive memory pool.

  • The adaptive eviction is an excellent improvement over static trimming.
  • Thread safety, metrics, and defensive checks are all strong.
  • The only possible enhancement would be optional age-based eviction for even more precise memory usage control, but for most server workloads, this scheme is ideal.

Overall: 👍 Looks great and ready for real workloads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions feature-httpsys feature-iis Includes: IIS, ANCM feature-kestrel
Projects
None yet
6 participants