Garbage Collection
craftinginterpreters.com
craftinginterpreters.com
I wonder how they decide which objects are rooted only by the stack. Is it part of the write barrier, or computed at mark time?
https://github.com/lua/lua/blob/6f1c033d72af8fe65bb67e17a242...
This can go really bad, for example in systems that keep caches/repositories of preallocated things for faster re-use. If they are anchored at global variables, or near global, then you need to treat those preallocated things special. You can't just go and move them every time even when knowing you will never collect them.
Generally, GC works better if you don't do tricks/optimizations with memory allocation and let just everything flow freely into the heap. If you do have to optimize allocation you generally have to teach your GC about your hack.
I'm kind of a fan of out-of-process, same-system caches for this reason.
https://en.wikipedia.org/wiki/Automatic_Reference_Counting
https://en.wikipedia.org/wiki/Smart_pointer
They almost "just work", except for circular references:
https://en.wikipedia.org/wiki/Reference_count#Dealing_with_r...
I'd like to see some real-world studies on what percentage of unclaimed memory is taken up by orphaned circular references, because my gut feeling is that it's below 10%. So that really makes me question why so much work has gone into various garbage collection schemes, nearly all of which suffer from performance problems (or can't be made realtime due to nondetermistic collection costs).
Also I can't prove it, but I think a major source of pain in garbage collection is mutability, which is exacerbated by our fascination with object-oriented programming. I'd like to see a solid comparison of garbage collection overhead between functional and imperative languages.
I feel like if we put the storage overage of the reference count aside (which becomes less relevant as time goes on), then there should be some mathematical proof for how small the time cost can get with a tracing garbage collector or the cycle collection algorithm of Bacon and Paz.
Well, and they're often slower since they require mutating the reference count on every single time a field is stored. You can optimize that using lazy reference counting, or not updating refcounts for references on the stack and instead scanning the stack before a collection. But at that point... you're halfway to implementing a tracing collector.
Every refcounting implementation eventually gets "optimized" to the point that it has most of a tracing collector hiding inside of it. I think it's simpler, cleaner, and faster to just start with tracing in the first place.
There's a reason almost no widely used language implementation relies on ref-counting. CPython is the only real exception and they would switch to tracing if they could. They can't because they exposed deterministic finalization to the user which means now their GC strategy is a language "feature" that they're stuck with.
That being said, ref-counting is fine for other kinds of resource management where resources are pretty coarse-grained and don't have cyclic references. For example, I think Delphi uses ref-counting to manage strings, which makes a lot of sense. Many games use ref-counting for tracking resources loaded from disc, and that also works well. In both of those cases, there's nothing to trace through, and the overhead of updating refcounts is fairly low.
Reference counting and address-semantics (which prohibits moving objects, so even if you work around the refcount, you can only do a tracing GC, but you don't get to compact) are deeply ingrained into CPython's C API as well, which is very widely used.
https://dl.acm.org/citation.cfm?id=1028982
This had a number of important corollaries to this. One is that they have opposite performance characteristics: GCs make creating new references fast but then create a large pause when RAM is exhausted and a collection happens, while refcounts make creating new references slow and create a large pause when large object graphs are freed all at once. Another is that nearly all high-performance memory management systems are hybrids of the two: lazy refcounting is basically like implementing a local garbage collector and then feeding the live set of that into the refcounts, while generational garbage collection is like implementing a local refcount (the write barrier) and feeding the results of that into the root set.
ObjC had some elision tricks like objc_retainAutoreleasedReturnValue, but more importantly the optimizer was taught about RC manipulation. Swift then extended this with a new ABI that minimizes unnecessary mutations.
The big advantages of this scheme are efficient COW collections and a simpler FFI (very important with Swift). More generally RC integrates better. Imagine teaching the JS GC to walk the Java heap!
Also, the Swift implementation is a bit questionable if performance is a goal. That is, why not try to remove the memory management from the inner loop? Probably the first thing to try is value types instead of reference types, which are more generally preferred anyway.
I believe all general purpose languages let the code allocate memory and therefore will let you allocate memory in a way inefficient to your task.
Hence why there is so much emphasis on value driven programming alongside protocols at WWDC talks.
Cough. Actually, before ARC the ownership rules really kept RC traffic very low, and you could drive it lower still if you knew a bit about how ownership worked in your code ("these will balance and the object is already owned elsewhere...").
ARC just went mad with trying to give guarantees it really had no business making, and then hoping the compiler would be able to remove them again (difficult with message sending).
This caused some amusing bugs, for example a crash in the following method:
-someMethod:arg and:arg2
{
return 0;
}
How could this possibly crash? All it could possibly do is clear register AX and return.Well, with ARC enabled and in debug mode, it inserted code to first retain all the arguments, and then immediately release all the arguments. Apparently one of the args was a bogus pointer (framework-provided, so out of our control) and so it crashed.
return 0
is now considered "undefined behavior".Whatever.
You can try to defend yourself by stating the call is done by some framework but that just means the problem is still not in the language, it’s in the framework.
Second, whether the behavior is defined or undefined, the compiler has no business touching arguments that the code didn't tell it to touch. If it does so, it isn't fit for purpose and can be criticised as being unfit for purpose.
And the C standard is very clear and adamant that being in compliance with the standard is not, and in many ways cannot be, equivalent to being fit for purpose.
Last not least, I think we are probably not going to agree as to whether dereferencing unused arguments is a valid response to the presence of undefined behavior. See
https://blog.metaobject.com/2018/07/a-one-word-change-to-c-s...
Perl is also ref counted. Swift too.
Edit: Also TCL, and PHP is mostly recounted...the "GC" just deals with circular refs.
The problem with predictable destruction, we typically called "timely destruction", but more accurate would've been "immediate destruction" and if your language guarantees it, it tends to be great for automatically releasing resources (memory, file handles,...) in a lexically predictable manner. Softening any such guarantee should lead to entertaining resource races magically appearing in the real world. It occurs to me now that I never checked what pypy guarantees if anything?
We (Perl) never really came up with a great strategy for working around this (you tended to get the worst of both worlds in most naive reimplementations of the behavior in a GC-based interpreter) and while I certainly haven't tried to work out a clean argument to the effect, I'm fairly convinced it won't fly at all.
{
# do stuff
my $dbh = DB.connect(...);
# do stuff
LEAVE .disconnect with $dbh;
# do stuff
}
Whenever the scope is left (this could be because of an execution error, or a `return`), the code of the LEAVE phaser will be executed. The `with $dbh` checks whether the $dbh variable contains an instantiated object. If so, it topicalizes (set $_ to it) and calls the disconnect method (`.disconnect` being short for `$_.disconnect`).For more complex uses, and more tuneable finalizing, there is the `FINALIZER` module in the ecosystem: https://modules.raku.org/dist/FINALIZER
It's just another tool in Rust's box (no pun intended) but I wouldn't say it hasn't caught on. It's used quite often, along with its multithreaded cousin std::sync::Arc.
More generally there's an ABI. While to Rust "Arc is just another tool", reference counting is deeply pervasive on iOS and macOS. You would not attempt to pass a Rust Arc to C, and expect something useful to happen. But Clang's ARC works well with C, and Swift, and with ObjC and even with ObjC-pre-ARC. This isn't a critique of Rust, just reflecting different priorities.
1: https://clang.llvm.org/docs/AutomaticReferenceCounting.html#...
ARC makes sense given Objective-C compatibility, as otherwise a layer similar to RCW on .NET for dealing with COM/UWP would be needed.
And Objective-C only dropped its tracing GC, because it was too unstable while mixing GC enabled frameworks with classical ones, while making the compiler automatically call retain/release would provide better productivity without being unstable.
Actually, you couldn't mix them. The GC simply never really worked properly.
But as you say, it never worked properly.
Which is similar but not quite the same thing. Mixing pure GC and pure RC frameworks was never possible. In fact, I think there were some flags and the (dynamic) linker would balk.
Do you have examples where Swift suffers compared to other languages solely because of ARC?
Also, is this something that Apple might theoretically make up for by optimizing their custom CPUs for it, without changing Swift?
https://github.com/ixy-languages/ixy-languages
Since the Xerox PARC workstations and Genera Lisp Machines, all hardware specific instructions for memory management have proven to be worse than doing it eventually in software.
As for memory management impact in overall performance, a similar set of benchmarks would be needed.
Literally all of them. Every single program where the ARC system is actually widely used.
Swift otherwise generates really good code, and should be meeting/beating Java most of the time, but if the ARC system is used, performance instantly takes a massive hit. This is very visible in the small synthetic benchmarks at:
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
In every benchmark where it was possible to write the program so that no references are updated in the hot loop, Swift comes out really impressive, beating Java. In benchmarks where reference updates are unavoidable, performance is more in line with Python.
> Also, is this something that Apple might theoretically make up for by optimizing their custom CPUs for it, without changing Swift?
There are various theoretical ideas that have been bounced about for decades, but so far none of those has panned out. While Apple might do that too, at this point they seem to be chasing the opposite approach:
https://github.com/apple/swift/blob/master/docs/OwnershipMan...
Essentially, nicking the ownership/lifetimes system out of Rust, to avoid updating references wherever possible. The main difference from Rust will be that the current way of doing things will still be possible and the default, but programmers can opt into more performance by decorating their types with lifetimes.
I am cautiously optimistic for this approach: I love Rust, but learning it did definitely feel like being tossed right into the deep end. Which was filled with sharks. If Swift can succeed in a softer approach of introducing lifetimes, that might help the wider introduction of the concept.
Interestingly enough, the semi-automatic reference counting mechanism used previously was reasonably fast if used as-is and could be made very fast with a bit of thought.
The reason reference counting is not more common is because of its performance. Naive reference counting works in some cases (e.g. when the counts are not modified often), but doesn't work too well for most programming languages, as the reference counts will be modified frequently.
Deferred and coalesced reference counting are two techniques of improving performance, but they come at a cost: they are quite complex to implement, and require additional memory to keep track of what/when to increment/decrement. You will also end up facing similar nondeterministic behaviour and pauses, as an optimised RC collector may still need to pause the program to scan for cycles. You can handle cycles by supporting weak and strong references, but this puts the burden on the developer, and it's not always clear if cycles may appear when writing code.
Combining this with a good allocator you can achieve performance that is on par with a tracing collector (http://users.cecs.anu.edu.au/~steveb/pubs/papers/rcix-oopsla...), but it's not easy. If you are going down the path of implementing a complex collector, I think you're better off focusing on a real-time or concurrent collector.
It is true that you can get cyclical refs higher up in the language even with functional code though, and at some level you need a cycle checker.
There is another type of managed memory not talked about much, and that's the model used by Composita- RAII but with defined message interfaces between modules, such that you can deterministically allocate memory for that:
http://concurrency.ch/Content/publications/Blaeser_ETH_Diss_...
It looks to be the best way of doing deterministic performant memory management, though you'll need a cycle checker in the language runtime.
I can only speak for Haskell's GC, but it's pretty conventional. You might think that GC is simpler for Haskell without mutability but you'd be wrong, because (1) Haskell actually gives you plenty of safe and unsafe ways to have mutability, safe ways such as the ST monad (not to be confused with the State monad), (2) laziness effectively is mutation: evaluating a value is effectively overwriting the closure to compute the value with the value itself. The lazy list is a pretty common sight in most Haskell code. So basically Haskell has a pretty conventional stop-the-world, generational GC not unlike imperative languages.
We can compare memory strategies like so:
Garbage Collection:
- Allocations: Very fast (often a single instruction)
- Ownership transfer: Free
- Pointer release: Zero
- Post-free: Garbage collection phase (this is where the time is spent)
Reference Counting:
- Allocations (similar to malloc(), requires a binary search at least)
- Ownership transfer: At least one instruction for single-threaded. Multi-threading: Requires a lock.
- Pointer release: Slow. Locking issues, memory housekeeping.
- Post-free: Zero. Good for realtime.
The first point might require some clarification. When you have a compacting garbage collector, the free memory is usually in a single block. This means that allocations merely require a single pointer to be updated. If the pointer hits the end of the free block, you trigger a GC. You don't even have to check for the end of the free block if the subsequent memory page is marked no-access.
One can spend a lot of time measuring the performance impact of all these different steps, and I am not going to try to prove that GC is always faster than refcounting, but at least it should be clear that it's not a simple matter of assuming that having no GC means that you will have no memory management overhead.
(Also, "ownership transfer" can be free in reference counting, since the number of references is the same before and after so there's no need to modify the reference count. What isn't free is sharing ownership, since it needs reference count manipulation.)
As for the second paragraph, thank you for clarifying that. I used poor terminology. I should probable have said pointer sharing, or copying.
For ownership transfer to be zero cost, the compiler have to be clever enough to figure out that the original reference isn't used after copy. This can be handled by the compiler itself, or be enforced by the language (as is the case with Rust, as far as I understand).
GC uses additional memory to amortize collections over allocations. (Zero overhead means constantly collecting, using 2x steady state memory size is not uncommon.)
Overall, I think you're right, refcounting comes out ahead on memory usage. But it's not completely straightforward to determine.
Smart pointers are a central feature of C++ even before they were added to the C++ standard in 2011.
The reference is provided by Ravenbrook, a consulting company formed from the ashes of Harlequin (who made lispworks, a CL implementation and IDE; MLWorks, the same for SML; and ScriptWorks, a postscript rasteriser which made them all their money). I don’t know when the reference was created.
https://medium.com/@MartinCracauer/generational-garbage-coll...
And a review of LLVM's GC facilities: https://medium.com/@MartinCracauer/llvms-garbage-collection-...
Overall message is that there are many different ways to get to the destination, and creative solutions are mixed in.
A better questions would be whether anybody has a moving, precise GC running on LLVM. Clasp with MPS runs, but that might be coincidence/luck.
Edit: My mistake; he discussed it back in chapter 3.
"A Unified Theory of Garbage Collection" https://researcher.watson.ibm.com/researcher/files/us-bacon/...
> Tracing and reference counting are uniformly viewed as being fundamentally different approaches to garbage collection that possess very distinct performance properties. We have implemented high-performance collectors of both types, and in the process observed that the more we optimized them, the more similarly they behaved— that they seem to share some deep structure.
> We present a formulation of the two algorithms that shows that they are in fact duals of each other. Intuitively, the difference is that tracing operates on live objects, or “matter”, while reference counting operates on dead objects, or “anti-matter”. For every operation performed by the tracing collector, there is a precisely corresponding anti-operation performed by the reference counting collector.
> Using this framework, we show that all high-performance collectors (for example, deferred reference counting and generational collection) are in fact hybrids of tracing and reference counting. We develop a uniform cost-model for the collectors to quantify the trade-offs that result from choosing different hybridizations of tracing and reference counting. This allows the correct scheme to be selected based on system performance requirements and the expected properties of the target application.
Mine too. It's a gem.
IMO, one of GC's benefits is that it maintains data locality. Otherwise given GC is not that different from reference counting MM (loops in ownership graphs aside). Same problems with memory fragmentation inherited from malloc/free.
That too but also, if you have array of objects, then compacting GC will replace those objects in continuous area in memory. So conceptually it will optimize access to those objects. Cache prefetching by modern processors, all that.
Keep in mind that you only have a tiny amount of L1 data cache lines. They are gone so quickly. If you can get a couple more struct instances in an array into those cache lines (without the cache lines holding unrelated nonsense as a byproduct of a memory fetch) that is a huge win.
The issue of L1 cache lines is more important than the size of the L1 cache. The granularity of the cache lines uses up the size of the cache very quickly if all cache lines are padded up with 3/4rd nonsense that you don't need right now.
It seems like it will vary a lot depending on details of the language implementation. An array of value types should be laid out linearly, but beyond that it seems hard to tell what's going on?
Let later placement be decided by the order in which the heap is scavenged. That is what e.g. re-unifies array-of-pointer target instances. You scavenge the array of pointers and in the default case you line up the instances the pointers point to beautifully one after another. Even if the initial allocation had interleaving other memory. In that case your code gets faster after GC than before the data is first GCed.
That's exactly why I was excited to write this chapter. They have a reputation for being hard, but the core algorithm is really just a simple graph traversal.
The reason being that allocation can be much faster. The GCed code can allocate memory with as much as an increment of a pointer (and that is atomic, so no thread locking needed).
malloc/free always do full function calls, and they might/will descent into dealing with fragmentation (aka finding free space). Likewise, free() isn't free and cross-thread allocation/deallocation can further complicate things.
But if you cannot move memory (adjust pointers like most GCs do) then you will have to deal with fragmentation, which slows down allocation (or causes other drawbacks).
Moving GC can also make the program faster due to memory compactation and hence being more efficient wrt CPU cache and TLB.
Turned out the vast majority of allocations are tiny object instances of a handful of different sizes. As a result minimizing fragmentation is as easy as allocating pools of a fixed size for small objects, and round up larger objects to a multiple of block size. There are still truly pathological patterns possible, and a compacting / generational gc may still be worth it later both to deal with that and to reduce the cost of a collection, but for a lot of uses you can get away with simple options like that.
Nitpick (and mostly for others reading): incrementing the address of a pointer if done locally (as in, using a local variable of sorts) may be atomic, but storing the updated pointer somewhere may not. If a bump allocator wants to support concurrent allocations, it should make sure to use the right atomic operations (typically this just a compare-and-swap of the old pointer with the locally incremented one).
https://journal.stuffwithstuff.com/2013/12/08/babys-first-ga...
I've implemented a couple of scripting languages, but thus far I've ignored GC. It is something I'd like to experiment with in the future though.
https://en.wikipedia.org/wiki/Region-based_memory_management...
Imagine creating a Rust program entirely with `Rc`. It's basically a GC'd program at the point where the "roots" are managed by the reference counter. The "list of roots" is only ever messed with whenever a `Rc` is dropped/created, and one can optimize functions to take `&Rc` to reduce "GC" pressure. I do not believe it's possible to automate this process in general because if you could, I have a hunch the solution can be used to decide the Halting Problem.
So sure, in general a GC can perform some heuristics to predict the lifetime of an object, but usually the point of using a GC is that one does NOT know the lifetime or it is insanely complex.
Without these restrictions, this problem is isomorphic to the halting problem. (Proof: assignment of a given memory object to a field within another unrelated object creates another reference. The job of automatic memory management is to determine when no such references exist. Now replace that assignment with HALT. Any such automatic memory manager that operates statically would be able to find all HALT statements within the program and so solve the halting problem for an arbitrary program.)
That's why languages that manage memory statically like Rust & C++ must be able to reject some programs as "not passing the borrow-checker", and everything else requires run-time support via either GC or refcounting.
Depending on what one is trying to do, those constructs can still be allowed on safe code, or require explicit unsafe modules (or code blocks).
Examples, D, Nim, Swift, Mesa/Cedar, Modula-2+, Modula-3, Sing#, System C# (aka M#), .NET (version and language dependent).
If you can answer that, then you can optimize accordingly.
E.g you can choose to allocate objects that won't be retained on the stack instead of the heap, or in separate blocks.
You can even potentially benefit from this even if you can't 100% know. If you use a generational collector, an object that almost certainly escapes could bypass the nursery, for example.
And on the more extreme case you can even replace the objects with a bunch of local variables. For example, replace a Point object allocated on the stack with a pair of variables gor the x and y coordinates.
> if (previous != NULL) {
> previous->next = object;
> } else {
> vm.objects = object;
Ergh. That's much more painful than it needs to be. Maybe try: static void sweep() {
Obj** ref = &vm.objects;
while (*ref != NULL) {
if ((*ref)->isMarked) {
(*ref)->isMarked = false;
ref = &(*ref)->next;
} else {
Obj* unreached = *ref;
*ref = unreached->next;
freeObject(unreached);
}
}
}Baby’s First Garbage Collector[0] (from the same author) uses this approach.
[0]: https://journal.stuffwithstuff.com/2013/12/08/babys-first-ga...
I wondered about that too. If you aren't doing an incremental collector, it's not really necessary. But I think it helps build a visual intuition for the algorithm (and other graph traversals for that matter), so I felt it was worthwhile to put in there.