Questions about ARC and manual allocation of class instances

Hello everyone! As a personal project and as an excuse to (finally) learn Swift (I got started in my programming career as an Objective-C developer), I've been writing a game engine lately in the language. My objective is to write a fast, user friendly, and multiplatform Swift game engine, with rendering backends for Metal, DirectX, and Vulkan.

Those of you who are familiar with game engine design will be aware that modern game engines tend to opt for Data Oriented Design approaches. These days, Entity Component Systems are also all the rage, which tend to lay out game Components in contiguous buffers and then crunch through them, asynchronously and concurrently, via Systems that ensure there's no dependency collisions or other multithreading issues.

Therefore, I've been designing my Swift game engine with these sort of approaches in mind! For multiplatform reasons and to ensure the various different rendering backends and whatnot can have easy, contiguous access to the game data, I've written some really basic allocation backend stuff in C. I need to manually allocate and manage my memory with C and get Swift to place nice with this.

I've been fairly successful with this. Using a combination of Wes Wickwire's Runtime, Jordan Rose's Swift-PPC work, and Robert Widmann's work on Swift Gestalt, I've been learning about how the Swift runtime works and how it allocates and lays out memory and so on. Using Value Witness Tables, I have fairly granular control over struct data and can pass this to and from my buffers and still play nice with ARC.

I've also figured out how to do this with object instances, basically placement new but in Swift. Same deal, I'm basically able to manually allocate/deallocate instances into/from whichever pointer I want. However, so far the way I'm handling instances is not quite as efficient as it could be since this part of the Swift runtime is a tad less documented. So here are my questions:

1. I don't know how to run my instance's (required) initialiser on my manually allocated instances.

My current workaround is to allocate a throwaway Swift instance the usual way, iterate over and copy all the properties over to my manual allocated instance, and then let ARC reclaim the throwaway instance. The copy properties stage is so any instances, contained either through value types or just ref themselves, aren't deallocated when the throwaway gets dealloc'd.

This is suboptimal because we're paying for a malloc->free with every instantiation, and depending on the platform might even cause memory fragmentation. I know that Swift's class metadata contains info about all the methods in a class and I could probably get at the function pointer for the initialiser through that, but it's unfortunately not well documented enough (Swift Gestalt gets close but it's still a tad unclear). I was wondering if anyone here is familiar enough about how instance initialisation works in Swift at the really granular level, and would be willing to explain it to me? I'm also unsure if the initialiser has code for the property initialisation bit of instance initialisation, or if that's something else done by the compiler.

2. I know how to run an instance's destructor, but the compiler also seems to do some work here?

An instance's destructor is much more easily accessible and that part of the metadata is much better documented (thanks Swift-PPC!), and so I can call an instance's destructor fairly easily. However, calling that only really runs the destructor, and any properties are not dealloc'd and are leaked, which hints to me that ARC is the one doing this work. What happens when the ref to an instance is decreased, or how does this work? Does ARC also trawl down all the properties of an instance and decrease the count of any refs 'owned' by this instance? Or, alternatively, does ARC only do this trawling once we know the parent's instance is about to be dealloc'd? Is recursive trawling how this whole thing happens, or how does it work? I'm basically trying to learn how and through what method is it that ARC does ref retains/releases through any refs 'owned' by anything to guarantee there's no leaking.

My current workaround is to just always keep my manually allocated instances at ref 1 and just call an Unmanaged.release when I need the destructor and release of any children to happen. It works well enough and ARC doesn't seem to mind my custom allocated buffers. I haven't yet done any experiments to see if ARC trashes my memory or somehow free()'s my buffer (unlikely?), but that would be easily fixed by doing another one of my inefficient throwaway duplication thingies I'm doing to construct instances. :P

3. Dangling refs/pointers and the refcount sidetables.

When one manually allocates stuff like this one has to be prepared to deal with dangling pointers, so that bit's expected and doesn't scare me too much, but through my investigation I noticed that when you create a weak retain to an instance you create a 'sidetable', which I imagine is some sort of cross-referenced pointer thing to ensure safety when the instance gets dealloc'd, to nil the weak ref.

Another optimisation that I'll need to do is to move instances around all over the place, so unfortunately the pointer to an instance can't be guaranteed to always be at the same memory address. I'm using the standard approach of just wrapping pointers in handles that deal with this, but I'm wondering if those sidetables mean that any accidental weak ref or something along those lines could cause any unexpected undefined behaviour later on? How does this aspect of ARC work?

Just use structs and protocols?

I'm well aware that all of this talk of contiguous buffers and whatnot would be best served by ditching instances entirely, and just using structs and protocols to achieve what I need to achieve (and would ironically mirror the designs of some of the more performance oriented C++ ECS systems out there). However, one of the requirements of my game engine is to be user friendly, and unfortunately the big player engines out there like Unity, Unreal, and what have you don't make their end users use structs, they use instances and inheritance and all that, and a struct design would make my engine harder to quickly take up for the sort of people I want to write it for (beginners, indies, those sorts of people). Therefore I'm really keen to get instances working just as nicely as structs with my system, and would appreciate any advice that helps me achieve that.

Thank you everyone for the help!

4 Likes

A cool project, if one stretching Swift past what it currently supports. Let’s see: for #3, the language is definitely working against you. Swift assumes that a class instance does not move around—if it has, it’s a different instance. The weak sidetable is the most general form of that, but it’s not the only place; === and ObjectIdentifier, for example, are wrappers around comparing addresses. You’d also have to restrict your movable classes from having any Objective-C-compatible weak members, since ObjC weak does not use an indirection but instead registers the address of the weak reference itself with the runtime. And of course you’d have to not persist any manual pointers into the class storage, like what ManagedBuffer provides. To top it off, the sidetable isn’t just for weak, although I don’t know offhand what else would use the backreference to the class instance.

(I’m not actually sure how you’re moving objects around, but I can imagine something very basic like realloc on a slab and assuming you’re doing something more complicated than that.)

For #2, the destructor in the class value witness table should be running any manually-written deinitializer, releasing any stored properties with ARC (the compiler knows what stored properties a class has), and finally deallocating the object (free(self), conceptually). The order in which these things happen is a little different between pure Swift classes and Swift classes with Objective-C ancestors, but all three tasks have to get done. In your case, I guess you’d substitute the last memory allocation step with something else? But I’m not quite sure how you’d end up with the behavior you’re describing.

For #1, Swift does not exactly expose the entry point you need. Most of Swift’s initializers are “allocating” initializers, which include the allocation of memory as the implicit first step. Only designated initializers get “initialize-only” entry points, so they can be called from subclasses using super.init. These entry points will get optimized away if the class is non-open, because they’re a waste of code size if nothing will call them.

That said, there’s nothing special today about the Swift allocator (well, there wasn’t in 5.1), so if you could call the initializing entry point directly it should work for your placement-new, possibly with some additional minor setup to set the isa and refcount fields correctly. I’m not sure offhand what it would take to reference that entry point directly, though.

A terminology nitpick: in Swift documentation, instance refers to any value of a nominal type (struct, enum, or class), while object is specifically “class instance”.

6 Likes

Hey, thank you so much for your reply! This is very illustrative. :slight_smile:

On #2, I was calling the function pointer to the destructor found at position -2 in the class metadata, not the function pointer from the value witness table. That makes a lot of sense! I'll run some experiments to see how that bit works.

On #1, the class is indeed open, but I also need to construct any subclasses the end user might come up with, which might not be open, so it sounds like my current workaround approach is the only practiceable solution.

Finally for #3... if I understand right, what you're suggesting is that as long as nothing outside of the engine holds a ref to the travelling class instance, then things should be OK? This is not unlike the standard C++/dangling pointer challenge.

Instances are moved around because they need to be sorted by entity archetype in order to keep things cache friendly, so as entities mutate, they must be resorted into a different contiguous buffer, so stuff like the renderer can crunch at them with the materials properly sorted, blah. Since at this point the instances are 'owned' by the C side of things, they just get memcpy'd around, since ARC can't get at them and the refcounts are still technically the same. And yes, all references to them are implicitly assumed to be unowned(unsafe) (and declared this way in the engine side because ARC is basically pointless, whenever they're not just void* in the first place).

So, my last question is... since moving instances around and therefore leaving the refs dangling is so unswiftlike... how much of a problem do you think this is? I'm not against looking at the Swift heapobject source and patching in some custom ref magic that stops/nils/deals with the indirected side table whenever a move happens. And, like any C++ project that shuffles memory around all over the place, the design for the end user would expect them to use Handles to any engine allocated class instance, not refs, and the Handles can never dangle. That said, I can't stop the end users from storing a ref to the class instance through the handle, since the handle has to expose the (unsafe, unowned) ref in order to expose the methods for the class instance.

So, is this a paradigm that is game-breaking from a 'typical Swift' perspective? I'm trying to create a useable Swift game engine, not reinvent the language, and if Swift is really that dead-set on having the class instances have a fixed memory address and the whole thing will break from moving instances around, then it sounds like unfortunately the struct + protocol approach is the only Swifty way to do what I'm trying to do? What would be your advice?

Thanks again for taking the time to answer my questions, I really appreciate it!

1 Like

Just keep in mind that by using classes you’re also going to make it way easier for them to create bugs and performance problems. Sure, classes are “friendly” but with friends like those, who needs enemies?

4 Likes

Ah, I think your way was right. Even if that doesn’t deallocate the class’s memory, it should still be releasing references in stored properties. (I don’t remember whether it’s supposed to deallocate or not though.)

Probably. There’s still no guarantee the runtime won’t need the sidetable for something besides weak references, but even then it might not need the backreference stored in the side table? And yeah, then it’s just about controlling references from outside.

One idea on that: you can check that after each use of a Handle, you can check isKnownUniquelyReferenced to see if user code has escaped any strong references. It isn’t guaranteed to catch weak/unowned/unmanaged references, though, so it’s just protection against obvious user error.

I’ll note again that Swift types are not guaranteed to be what C++ calls “trivially movable”, i.e. move-by-bitwise-copy, though in practice all of them except certain weak references are today. But I imagine you’ll have rules about what stored properties these entities support anyway?

For me personally, it’s getting to that point. You’ve got things that are almost but not quite like regular classes, and if the differences can’t be checked, it’s a pitfall for your users, one that in the worst case could break memory safety. But giving up inheritance is unfortunate; entities in a game do naturally lend themselves to inheritance. Then again, Dave’s right that these sorts of systems don’t really treat entity instances as reference types; they may have identity, but that identity isn’t an address in memory.

3 Likes

Hey! Thanks again for your response.

I've decided to ditch the class instances (for user facing code anyways) and go with a Structs + Protocols approach for most engine data types. At this point it really does feel like it's the only sane way forward. Functional programming wins yet again!

That said, I had a lot of fun poking about in the Swift runtime, and I see no reason why internally I can't use my custom-allocated class instances to do little things here and there (mainly because I'm proud I got them to work and don't want to let them go!), so hopefully that will be a segway for further Swift runtime experimentation knowledge that can find its way back to the community in due time. :slight_smile:

Thank you again for all your help!

7 Likes