Skip to content

High gen0 collect overhead with (suppressed) finalizer objects #48937

@peppy

Description

@peppy

Overview

When allocating many objects with a finalizer present, there is a non-negligible overhead on GC gen0 collects, even if the finalizer has been suppressed via GC.SuppressFinalize. The hypothesis is that this is due to the emptying of the finalizer queue.

This is causing our real-time application to hitch on every gen0 collection (around 5-10ms pause time). These gen0 collections only happen every 20-60 seconds.

Use Case

A bit more information on our situation, in case it helps to put things into perspective.

Our team develops a rhythm game/framework which requires sustained very low latency execution. We have recently been tracking user reports of occasional high frame times which align with GC invocation, specifically gen0 collections.

Having worked with .NET (framework / core) for several decades, I have a general idea of what to expect in terms of gen0 collection performance, and the numbers we are seeing are much higher than expected, in the range of 5-15ms per collection with low (<1MB/s) alloc throughput and near zero promotion.

One cause turned out to be a texture upload class we have, which rents memory from ArrayPool and returns on disposal. This class may be constructed every frame for streaming texture data. While we do our best to explicitly dispose after consumption, it has a finalizer implemented as a safety measure, to ensure the memory is returned to the ArrayPool no matter what (I think this is a pretty common practice).

With our findings here, it seems that finalizers should be avoided in such cases, where objects are constructed in abundance. This is our general direction to resolve this issue, for what it's worth.

Reproduction

using System;

namespace TestBasicAllocs
{
    public static class Class1
    {
        public static void Main(string[] args)
        {
            bool finalizers = args[0] == "1";

            for (int i = 0; i < 10000000; i++)
            {
                if (finalizers)
                {
                    var thing = new FinalizingThing();
                    GC.SuppressFinalize(thing);
                }
                else
                    new NonFinalizingThing();
            }
        }
    }

    public class NonFinalizingThing
    {
        public NonFinalizingThing()
        {
        }
    }

    public class FinalizingThing
    {
        public FinalizingThing()
        {
        }

        ~FinalizingThing()
        {
        }
    }
}

Results

dotnet-trace collect -- .\bin\Debug\net5.0\TestBasicAllocs.exe 0

Photo Mar 2, 2021 05809

dotnet-trace collect -- .\bin\Debug\net5.0\TestBasicAllocs.exe 1

Photo Mar 2, 2021 05818

I am writing this issue up without a clear distinction of whether this should be considered a bug, performance issue, or an accepted (and potentially better-documented) hidden overhead of finalizers. I have read through .NET memory performance analysis documentation, which does mention that finalizers should be avoided, but also that calling GC.SuppressFinalize should recover all but the allocation overhead. Similar information is present in "official" documentation and user comments, but I have been unable to find anything referencing a gen0 collection-time overhead.

Also worth noting that while memory analysis guides and profilers like Jetbrains dotMemory will highlight finalizers that were not suppressed, it cannot provide visibility and does not find issue with large numbers of allocations of objects with finalizers present in general. Maybe in the majority of cases this pause overhead is considered acceptable, but do consider that the above benchmarks are cases where gen0s are happening quite regularly. In our actual usage we have seen pause times as long as 30-50ms due to the same underlying issue, which implies that this overhead is not part of the consideration as to how often to run gen0 collects.

I have tested against net472, .net core 3.1/3.2/5.0 and this is not a regression.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    No status

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions