Skip to content

runtime: improve AddCleanup performance #75188

Open

Description

@podtserkovskiy

Overview

Meta widely uses CGo, allowing Gophers to access a vast array of C++ libraries.
We previously advocated for the defer Close() pattern for C++ bounded objects; however, this defer call was frequently overlooked, leading to memory leaks as these objects resemble regular Go objects.
Consequently, we adopted automated memory management through runtime.AddCleanup.
Unfortunately, adding these cleanups incurs a performance penalty, specifically during the execution of the runtime.AddCleanup function.

Benchmark

Before implementing this change, we aimed to understand its performance implications.
Several online blog posts compare manual deallocation with runtime.SetFinalizer, highlighting the poor performance of finalizers. We conducted a similar benchmark using the brand new runtime.AddCleanup, introduced in Go 1.24.
While runtime.AddCleanup shows a 2x performance improvement, it still lags behind manual deallocation.

The following benchmark results on Go 1.25.0 on a MacBookPro M1 14" indicate approximately a 5x slowdown compared to manual deallocation (benchmarking on a Linux server yielded similar results):

BenchmarkAllocateFree-8                 15169078                73.42 ns/op
BenchmarkAllocateDefer-8                15890224                75.04 ns/op
BenchmarkAllocateAddCleanup-8            3516811               359.7 ns/op
BenchmarkAllocateWithFinalizer-8         1739244               676.9 ns/op

For the benchmark code and additional results, please refer to podtserkovskiy/slow-addcleanup-repro

Further investigation

Delving deeper into this issue, we believe the root cause lies in the linear search within the sorted linked list of special objects on mspan, specifically in the *mspan.specialFindSplicePoint function. A CPU profile generated on Go 1.25rc2 with GOGC=off (for a less noisy picture, as it doesn't significantly affect the results) illustrates this:

Image

Is there a potential to accelerate the *mspan.specialFindSplicePoint function (e.g., by using a skip-list instead of a plain linked-list)?

Activity

  1. podtserkovskiy commented on Aug 29, 2025

    @podtserkovskiy
    ContributorAuthor
  2. added
    ImplementationIssues describing a semantics-preserving change to the Go implementation.
    on Aug 29, 2025
  3. changed the title [-]runtime: Improve runtime.AddCleanup performance[/-] [+]runtime: improve runtime.AddCleanup performance[/+] on Aug 29, 2025
  4. self-assigned this
    on Aug 29, 2025
  5. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Aug 29, 2025
  6. added this to the Backlog milestone on Aug 29, 2025
  7. changed the title [-]runtime: improve runtime.AddCleanup performance[/-] [+]runtime: improve AddCleanup performance[/+] on Aug 29, 2025
  8. mknyszek commented on Aug 29, 2025

    @mknyszek
    Contributor

    I have some ideas on how to make this faster, at the cost of some memory overhead. What we brainstormed at GopherCon was essentially:

    1. Add an optional array of pointers to each mspan which contains a pointer to the first special for each object.
    2. Create this array on-demand, when the first object is added.

    If the size of the array is a problem (as it might be for, say, 8 byte objects) we can also try compressing it. For example, doing a linked list of chunks with some special compact encoding.

    The microbenchmark is maximally pessimal, since it forces a full walk of the linked list. In practice I suspect that it's not quite so bad, since the linked list for each span is going to be fairly short.

    That being said, it's still not fast. How big are the objects that you're actually applying the cleanup to in production?

  9. podtserkovskiy commented on Aug 31, 2025

    @podtserkovskiy
    ContributorAuthor

    The microbenchmark is maximally pessimal, since it forces a full walk of the linked list. In practice I suspect that it's not quite so bad, since the linked list for each span is going to be fairly short.

    Yeah, I agree the actual impact on the production services isn't studied and nobody reported their services is slow because of this.

    How big are the objects that you're actually applying the cleanup to in production?

    The current size is 16 bytes, but it can be reduced (and will be soon to 8 bytes) by swapping fields as we didn't pay a proper attention to alignment/padding.

  10. mknyszek commented on Sep 2, 2025

    @mknyszek
    Contributor

    The current size is 16 bytes, but it can be reduced (and will be soon to 8 bytes) by swapping fields as we didn't pay a proper attention to alignment/padding.

    Ah sorry, I also meant to also ask: how much total memory does each cleanup represent? Like, how much memory on the C++ side is being cleaned up by each cleanup, would you say, on average? That would help give me a better idea of what the current overheads are.

  11. podtserkovskiy commented on Sep 4, 2025

    @podtserkovskiy
    ContributorAuthor

    how much total memory does each cleanup represent? Like, how much memory on the C++ side is being cleaned up by each cleanup, would you say, on average?

    I'm trying to get this data. I hope we'll be able to provide it this week.

  12. podtserkovskiy commented on Sep 9, 2025

    @podtserkovskiy
    ContributorAuthor

    how much memory on the C++ side is being cleaned up by each cleanup, would you say, on average?

    I've talked to our language interoperability team. They don't see an easy way to measure it as some C++ classes are pretty basic, but others may refer to arbitrary size of binary data or to other objects via smart pointers.

  13. qmuntal commented on Sep 14, 2026

    @qmuntal
    Member

    The linear scan in specialFindSplicePoint may be a bottleneck, but per-cleanup allocations also appear to contribute meaningfully to AddCleanup’s overhead.

    Registration currently allocates both argument storage and a call adapter, adding allocator work and GC pressure on top of maintaining the special list. For the small wrappers described here, that bookkeeping can be significant relative to the actual work. I'll be working on this soon.

  14. gopherbot commented on Sep 14, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/832044 mentions this issue: runtime: avoid allocating cleanup call adapters

  15. gopherbot commented on Sep 14, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/832025 mentions this issue: runtime: avoid boxing pointer cleanup arguments

  16. gopherbot commented on Sep 14, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/832124 mentions this issue: runtime: remove redundant cleanup race synchronization

  17. assigned and unassigned on Sep 14, 2026
  18. modified the milestones: Backlog, Go1.28 on Sep 14, 2026
  19. gopherbot commented on Sep 14, 2026

    @gopherbot
    Contributor

    Change https://go.dev/cl/832324 mentions this issue: runtime: speed up special-list insertion

  20. qmuntal commented on Sep 14, 2026

    @qmuntal
    Member

    I've sent CL 832324, , targeting the common allocate-then-register pattern used by the reproduction.

    A typical use is to allocate a wrapper around a resource and immediately attach its cleanup. Small-object allocation advances through the free slots within a span, so registrations made this way tend to arrive in increasing offset order. The specials list is sorted by offset, but each insertion currently starts searching from the head, even when the previous insertion was immediately before it.

    The CL remembers the last inserted special per span. When the next insertion belongs after it, the search can skip that prefix of the list. Otherwise, it falls back to the existing search. The hint is cleared on searches and sweeping so removals cannot leave a stale cached record.

    The suggested skip list or per-object index would help more general access patterns, but require more bookkeeping. This approach keeps the existing list and adds just one pointer per span with no growth in individual special records.

    With the unchanged reproduction on Windows/ARM64, normal GC, and ten paired samples:

    • AddCleanup: 577.9 → 208.1 ns/op, 64% faster.
    • Finalizer: 776.5 → 408.4 ns/op, 47% faster.

    @podtserkovskiy is this enough for your use case? If so, feel free to close this issue.

  21. podtserkovskiy commented on Sep 21, 2026

    @podtserkovskiy
    ContributorAuthor

    Thanks, this looks much better! The goal here was just to highlight the performance difference, not to hit a strict target for AddCleanup. Happy to close this issue once the CLs merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

ImplementationIssues describing a semantics-preserving change to the Go implementation.NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.Performancecompiler/runtimeIssues related to the Go compiler and/or runtime.

Type

No type

Projects

Relationships

None yet

Development

No branches or pull requests

Issue actions