27

I've read in many places (heck, I've even written so myself) that garbage collection could (theoretically) be faster than manual memory management.

However, showing is a lot harder to come by than telling.
I have never actually seen any piece of code that demonstrates this effect in action.

Does anyone have (or know where I can find) code that demonstrates this performance advantage?

34
  • 6
    the problem with GC is that most implementations are not deterministic so 2 runs can have vastly different results, not to mention it's hard to isolate the right variables to compare Jul 4, 2013 at 19:49
  • @ratchetfreak: If you know of any examples that are only faster (say) 70% of the time, that's fine with me too. There must be some way to compare the two, in terms of throughput at least (latency probably wouldn't work).
    – user541686
    Jul 4, 2013 at 19:56
  • 3
    Well, this is a bit tricky because you could always manually do whatever gives the GC an edge over what you did manually. Perhaps it's better to restrict this to "standard" manual memory management tools (malloc()/free(), owned pointers, shared pointers with refcount, weak pointers, no custom allocators)? Or, if you permit custom allocators (which may be more realistic or less realistic, depending on what kind of programmer you assume), put restrictions on the effort put into those allocators. Otherwise, the manual strategy "copy what the GC does in this case" is always at least as fast as GC.
    – user7043
    Jul 4, 2013 at 20:41
  • 1
    By "copy what the GC does" I didn't mean "build your own GC" (though note that this is theoretically possible in C++11 and beyond, which introduces optional support for a GC). I meant, as I've worded it earlier in the same comment, "do what gives the GC an edge over what you did manually". For example, if Cheney-like compaction helps this application a lot, you might manually implement a similar allocation + compaction scheme, with custom smart pointers to handle pointer fixup. Also, with techniques like a shadow stack, you can do root finding in C or C++, at the expense of extra work.
    – user7043
    Jul 4, 2013 at 21:15
  • 1
    @Ike: It's okay. See why I asked the question though? That was the entire point of my question -- people come up with all sorts of explanations that should make sense but everyone stumbles when you ask them to provide a demonstration that proves what they say is correct in practice. The entire point of this question was to once and for all show that this can actually happen in practice.
    – user541686
    Jan 6, 2016 at 4:24

10 Answers 10

28

See Link and follow all of the links to see Rico Mariani vs Raymond Chen (both very competent programmers at Microsoft) dueling it out. Raymond would improve the unmanaged one, Rico would respond by optimizing the same thing in the managed ones.

With essentially zero optimization effort, the managed versions started off many times faster than the manual. Eventually the manual beat the managed, but only by optimizing to a level that most programmers would not want to go to. In all versions, the memory usage of the manual was significant better than the managed.

7
  • +1 for citing an actual example with code :) although proper use of C++ constructs (such as swap) isn't that hard, and would probably get you there quite easily performance-wise...
    – user541686
    Jul 4, 2013 at 21:11
  • 5
    You may be able to outdo Raymond Chen on performance. I am confident that I can't unless he's out of it due to being sick, I'm working many times harder, and I got lucky. I don't know why he didn't choose the solution you would have chosen. I'm sure he had reasons for it
    – btilly
    Jul 4, 2013 at 21:56
  • I copied Raymond's code here, and to compare, I wrote my own version here. The ZIP file that contains the text file is here. On my computer, mine runs in 14 ms and Raymond's runs in 21 ms. Unless I did something wrong (which is possible), his 215-line code is 50% slower than my 48-line implementation, even without using memory-mapped files or custom memory pools (which he did use). Mine is half as long as the C# version. Did I do it wrong, or do you observe the same thing?
    – user541686
    Jul 5, 2013 at 8:39
  • 1
    @Mehrdad Pulling out an old copy of gcc on this laptop I can report that neither your code nor his will compile, let alone do anything with it. The fact that I'm not on Windows likely explains that. But let's assume that your numbers and code are correct. Do they perform the same on a decade old compiler and computer? (Look at when the blog was written.) Maybe, maybe not. Let's suppose that they are, that he (being a C programmer) did not know how to use C++ properly, etc. What are we left with?
    – btilly
    Jul 5, 2013 at 14:29
  • 1
    We are left with a reasonable C++ program which can be translated into managed memory and sped up. But where the C++ version can be optimized and sped up farther. Which is what we all are in agreement is the general pattern that always happens when managed code is faster than unmanaged. However we still have a concrete example of reasonable code from a good programmer that was faster in a managed version.
    – btilly
    Jul 5, 2013 at 14:31
5

The rule of thumb is that there are no free lunches.

GC takes away the headache of manual memory management and reduces the probability of making mistakes. There are some situations where some particular GC strategy is the optimal solution for the problem, in which case you'll pay no penalty for using it. But there are others where other solutions will be faster. Since you can always simulate higher abstractions from a lower level but not the other way around you can effectively prove that there is no way higher abstractions can be faster than the lower ones in the general case.

GC is a special case of manual memory management

It may be a lot of work or more error prone to get better performance manually but that's a different story.

3
  • 1
    That makes no sense to me. To give you a couple of concrete examples: 1) the allocators and write barriers in production GCs are hand-written assembler because C is too inefficient so how will you beat that from C, and 2) tail call elimination is an example of an optimisation done in high-level (functional) languages that is not done by the C compiler and, therefore, cannot be done in C. Stack walking is another example of something done below the level of C by high-level languages.
    – J D
    Jan 27, 2016 at 0:03
  • 3
    1) I'd have to see the specific code to comment but if the hand written allocators/barriers in assembler are faster then use hand written assembler. Not sure what that has to do with GC. 2) Take a look here: stackoverflow.com/a/9814654/441099 the point is not whether some non-GC language can do tail recursion elimination for you. The point is that you can transform your code to be as fast or faster. Whether the compiler of some specific language can do this for you automatically is a matter of convenience. In a low enough abstraction you can always do this yourself if you wish.
    – Guy Sirton
    Jan 27, 2016 at 0:34
  • 1
    That tail call example in C only works for the special case of a function calling itself. C cannot handle the general case of functions tail calling each other. Dropping to assembler and assuming infinite time for development is a Turing tarpit.
    – J D
    Jan 27, 2016 at 0:54
3

It's easy to construct an artificial situation where GC is infinitely more efficient than manual methods - just arrange that there is only one "root" for the garbage collector, and that everything is garbage, so the GC step is instantly completed.

If you think about it, that is the model used when garbage collecting the memory allocated to a processes. The process dies, all it's memory is garbage, we're done. Even in practical terms, a process that starts, runs, and dies leaving no trace might be more efficient than one that starts and runs forever.

For practical programs, written in languages with garbage collection, the advantage of garbage collection is not speed but correctness, and simplicity.

17
  • If it's easy to construct an artificial example, would you mind showing a simple one?
    – user541686
    Jul 4, 2013 at 20:46
  • 2
    @Mehrdad He did explain a simple one. Write a program where the GC version fails to do a garbage run before exiting. The manual memory managed version will be slower because it was explicitly tracking and freeing stuff.
    – btilly
    Jul 4, 2013 at 20:52
  • 3
    @btilly: "Write a program where the GC version fails to do a garbage run before exiting."... failing to do garbage collection in the first place is a memory leak due to the lack of a functioning GC, not a performance improvement due to the presence of a GC! That's like calling abort() in C++ before the program exits. It's a meaningless comparison; you're not even garbage-collecting, you're just letting memory leak. You can't say garbage collection is faster (or slower) if you're not garbage-collecting to begin with...
    – user541686
    Jul 4, 2013 at 20:56
  • To make an extreme example, you'd have to define a complete system with your own heap and heap management, which would be a great student project but too large to fit in this margin. You'd do pretty well by writing a program that allocates and deallocates random sized arrays, in a way designed to be stressful to non-gc memory management methods.
    – ddyer
    Jul 4, 2013 at 21:00
  • 3
    @Mehrdad Not so. The scenario is that the GC version never happened to hit the threshold at which it would have done a run, not that it would have failed to perform correctly on a different data set. That is trivially going to be very good for the GC version, though not a good predictor of eventual performance.
    – btilly
    Jul 4, 2013 at 21:04
3

I have done quite a bit of work on this and described some of it here. I benchmarked the Boehm GC in C++, allocating using malloc but not freeing, allocating and freeing using free and a custom-built mark-region GC written in C++ all vs OCaml's stock GC running a list-based n-queens solver. OCaml's GC was faster in all cases. The C++ and OCaml programs were deliberately written to perform the same allocations in the same order.

You can, of course, rewrite the programs to solve the problem using only 64-bit integers and no allocations. Although faster that would defeat the point of the exercise (which was to predict the performance of a new GC algorithm I was working on using a prototype built in C++).

I have spent many years in industry porting real C++ code to managed languages. In almost every single case I observed substantial performance improvements, many of which were probably due to GC trumping manual memory management. The practical limitation is not what can be accomplished in a microbenchmark but what can be accomplished before a deadline and GC-based languages offer such huge productivity improvements that I never looked back. I still use C and C++ on embedded devices (microcontrollers) but even that is changing now.

15
  • +1 thanks. Where can we see and run the benchmark code?
    – user541686
    Jan 27, 2016 at 0:26
  • 1
    There are results for both thread safe and unsafe in there.
    – J D
    Jan 27, 2016 at 5:02
  • 1
    @Mehrdad:"Have you eliminated such potential sources of error?". Yes. OCaml has a very simple compilation model with no optimisations such as escape analysis. OCaml's representation of the closure is actually substantially slower than the C++ solution so it should really use a custom List.filter as the C++ does. But, yes, you are certainly quite right that some RC operations can be elided. However, the biggest problem I see in the wild is that people don't have the time to perform such optimisations by hand on large industrial code bases.
    – J D
    Jan 28, 2016 at 22:04
  • 2
    Yes, absolutely. No additional effort to write but writing code isn't the bottleneck with C++. Maintaining code is. Maintaining code with this kind of incidental complexity is a nightmare. Most industrial code bases are millions of lines of code. You just don't want to have to deal with that. I've seen people convert everything to shared_ptr just to fix concurrency bugs. The code is a lot slower but, hey, now it works.
    – J D
    Jan 29, 2016 at 0:42
  • 1
    +1 for mentioning programmer productivity. Something that is usually forgotten about when talking about performance, and something often more important than maximum possible performance.
    – JonasH
    Oct 14, 2022 at 14:57
2

It should be considered that GC is not just a memory management strategy; it also makes demands on the entire design of the language and runtime environment, that impose costs (and benefits). For example, a language that supports GC has to be compiled into a form where pointers can't be hidden from the garbage collector, and generally where they can't be constructed except by carefully managed system primitives. Another consideration is the difficulty of maintaining response time guarantees, as GC imposes some steps that have to be allowed to run to completion.

Consequently, if you have a language that is garbage collected, and compare the speed with manually managed memory in the same system, you still have to pay the overhead to support garbage collection even if you're not using it.

1

Faster is dubious. However, it can be ultra-fast, imperceptible, or faster if it's hardware supported. There were designs like that for LISP machines a long time ago. One built the GC into the memory subsystem of the hardware as such that main CPU didn't know it was there. Like many later designs, the GC ran concurrently with the main processor with little or no need for pauses. A more modern design is Azul Systems Vega 3 machines that run Java code way faster than JVM's using purpose-built processors and a pauseless GC. Google them if you want to know how fast GC (or Java) can be.

1

Perhaps the first academic paper that asserts this was "Garbage collection can be faster than stack allocation" (Appel, 1987) The assertion was true for certain use cases - in particular from the abstract:

An old and simple algorithm for garbage collection gives very good results when the physical memory is much larger than the number of reachable cells. In fact, the overhead associated with allocating and collecting cells from the heap can be reduced to less than one instruction per cell by increasing the size of physical memory. Special hardware, intricate garbage-collection algorithms, and fancy compiler analysis become unnecessary.

And from the paper's introduction:

This paper shows that, with enough memory on the computer, it is more expensive to explicitly free a cell than it is to leave it for the garbage collector — even if the cost of freeing a cell is only a single machine instruction.

In many practical use cases the live cells/objects at GC time is a small fraction of the total amount allocated in that GC period. (This of course is the observation that started all the R&D into generational garbage collectors.) And some language systems take this to an extreme: the functional languages. Functional languages are extremely heavy in their use of heap memory, and thus, fast GC is critically important.

The algorithm discussed in the paper is a stop-and-copy copying one in the context of one such functional programming system: An ML compiler.

A careful analysis is made showing that if the cost of popping a record from the stack, or of freeing heap allocated memory is (some) constant, then there's a cross-over point where, if there's sufficient free memory so that you don't need to GC that often, it's cheaper to free by copying active cells than by explicit frees of no-longer-needed allocated cells/objects. And then after that it's shown that allocation overhead can be zero if you can arrange to use an inaccessible page - thus a page fault - to signal it is time to GC. (And most OSes let you do that.)

Experiments were run on a special machine that allowed them to test performance as available memory was increased. The project was the "Massive Memory Machine" (at Princeton). The special "massive memory" machine was a VAX-11 with 128MB of memory. (Yes, that's right: Not terabytes, or even gigabytes. "Massive" was 128 megabytes. Well, it was 1986.)


Official full-text copies are easily found on the web.

0

Such an example necessarily has a bad manual memory allocation scheme.

Assume the best garbage collector GC. It internally has methods to allocate memory, determine what memory can be freed, and methods to finally free it. Together these take less time than all of GC; some time is spent in the other methods of the GC.

Now consider a manual allocator that uses the same allocation mechanism as GC, and whose free() call just sets aside the memory to be freed by the same method as GC. It doesn't have a scan phase, nor does it have any of the other methods. It necessarily takes less time.

11
  • 3
    A garbage-collector can often free up many objects, without having to put the memory into a useful state after each one. Consider the task of removing from an array-list all items meeting a certain criterion. Removing a single item from an N-item list is O(N); removing M items from a list of N, one at a time is O(M*N). Removing all items meeting a criterion in a single pass through the list, however, is O(1).
    – supercat
    Mar 10, 2014 at 2:57
  • @supercat: free can also collect batches. (And of course removing all items meeting a criterion is still O(N), if only because of the list traversal itself)
    – MSalters
    Mar 10, 2014 at 10:18
  • Removing all items meeting a criterion is at least O(N). You're correct that free could operate in a batch-collect mode if each memory item had a flag associated with it, though GC can still come out ahead in some situations. If one has M references which identify L distinct items out of a set of N things, the time to remove every reference to which no reference exists and consolidate the remainder is O(M) rather than O(N). If one has M extra space available, the scaling constant can be quite small. Further, compactification in a non-scanning GC system requires...
    – supercat
    Mar 10, 2014 at 16:08
  • 1
    @MSalters: "And what would prevent a deterministic scheme from having a nursery?". Nothing. OCaml's tracing garbage collector is deterministic and uses a nursery. But it is not "manual" and I think you are misusing the word "deterministic".
    – J D
    Jan 27, 2016 at 12:49
  • 1
    @supercat A specialized allocator can always be faster than a general allocator like the typical malloc/realloc/free implementation. However, a non-compacting GC built on top of any allocator will necessairly be slower than said allocator, and a compacting one will still be slower than a program which tracks it's pointer lifetimes.
    – yyny
    Sep 10, 2019 at 11:08
0

There are more than just two methods: The ones that I have used are garbage collection, manual memory management, manual reference counting, and automatic (compiler generated) reference counting.

One thing when you look at "faster": Something that is simpler is automatically faster. Say I write a program using manual memory management and it takes me ten days. And you write a program using garbage collection, and because this is easier, it takes you eight days. That means you have two days to make your code faster that I don't have, so whether garbage collection is faster or slower doesn't matter much. After ten days, your code will be faster. In the end, I don't care about the time for manual memory management because it is a pain. (C++ has things to reduce the pain though).

Reference counting has the disadvantage that some work needs doing every time a reference to an object is created or removed. Automatic reference counting helps with this, the compiler will often be able to remove matching pairs of add/remove reference. Knowing how it works helps: If you pass five strings to a function, that's five reference counts that need to be incremented / decremented. Pack the five strings into an object, and it's only one reference. Five times faster, and better for other reasons as well. And reference counting takes time. (Rumour is that Apple has made hardware changes to its ARM chips to make changing a reference count five times faster than on an x86; that may make a substantial difference).

And then comes the time where 1000 objects have been allocated and need releasing. The actual releasing may be faster with garbage collection, because it knows all pointers involved are valid. It may know that a whole area filled with objects can be released which may be only one operation. On the other hand, the speed of the actual allocation/deallocation without reference counting can and should be optimised. Quite conceivable that garbage collection beats a bad allocator, but not a good one.

And then there is the question how much time is wasted for objects that actually remain allocated during garbage collection. For reference counting etc. they have zero cost.

So I would say this is implementation dependent, it is dependent on exactly what you are doing, you can't change it so if you have a ton of Java or Objective-C code you have no choice anyway, it's not enough to choose one language over another, and you should use the most convenient of the available languages.

0

Efficient concurrent data structures for read access come to mind where GC of some form (including ref counting) is still arguably quite relevant even to me as a C++ programmer.

Read locks of any form (ex: shared mutex but also other forms) tend to be relatively very expensive in our use cases (many operations measured multiple times slower, even with relatively intensive processing per read iteration) due to the fact that we read over the same data in parallel across threads frequently (although writes are relatively infrequent) making the read lock highly contended (even when it's per-element). Since the read lock requires mutating data (effectively writing to shared memory atomically), this leads to false sharing all over the place just to read the elements of the concurrent structure with atomic writes involved to shared memory in what should conceptually be a read-only memory operation and definitely not an operation writing to memory that's shared between threads.

Handling Removal of Elements Still Being Accessed Without Read Locks to Access Elements

I cannot think of any solution to avoid mutating shared read lock states besides some form of GC (although I'm all ears if people have better ideas) which defers element removal/deallocation to a collector, since we have to be guaranteed that no other thread is reading the element in order to safely free it from memory.

Avoiding Shared Mutable State for Read-Only Access Using TLS

Some people might then think and point out that the GC state itself would requiring mutating shared data for reads, but we don't. We store a separate ref count in thread-local state per thread to avoid false sharing, and the collector then tallies them up in a stop-the-world fashion and frees the memory when the summed ref count is zero. This might sound explosive in memory use to have a whole separate set of thread-local ref counts per thread per element, but we don't do it per element; we do it for entire blocks of elements that can often contain as many as 4k elements each depending on the size of an element.

A Real-World Use Case

This is at least the best-performing solution I've come up with so far for our read-heavy but write-lights needs to avoid requiring threads to write to shared data (read lock data) when merely accessing the elements of the concurrent container in a strict read-only fashion, and it requires some form of GC. We use this solution in computer graphics with a highly parallelized entity-component system, and just using this GC solution here sped up an operation to load in a million polygon mesh from disk in a parallel loop to create the resulting mesh from 2 secs down to ~320ms, as the top hotspot profiling the code showed the times dominated by thread contention on the read locks of our concurrent structures[*]. This was also code that had been repeatedly measured and optimized by myself and others.

Loading a mesh from disk might sound like a write-heavy operation, but it tends to still be write-light and read-heavy. It's because meshes have to do a whole lot of reading in order to be created complete with not only polygons but edges, vertices, multiple texture UV coordinate maps, etc. For example, edge creation requires reading back the polygons of the mesh to determine what edges are shared between them. Even the process of creation of such structures are more read-heavy than write-heavy (unless we're just talking about triangle soup mesh reps and not ones like half-edges which share edges and vertices between polygons).

Anyway, this is at least an example of a real-world production use case sped up by the use of GC. At least I cannot think of how to avoid it and still speed things up without although I'm always interested in new ideas. But at least the simplest way to optimize this after mulling over it for days was to use GC to eliminate the requirement for read-locks in our iterators and random-access operators while still allowing element removal to occur in parallel while other threads are still reading that element. Often in talks about what's "faster" though, I think it's often a balance between efficiency, practicality for implementation, and the generality of the solution. GC is at least the best solution I could come up with (it was far from my first solution, and I've measured all of them) to achieve a balance of all three.

Not the answer you're looking for? Browse other questions tagged or ask your own question.