Swift Classes without ARC

Hey all, I used Swift macros to create make using classes without ARC possible. I called it NRC (no reference counting). Let me know what you think!

// Declaration
@NRC(
    members: [
        "var y": Int.self,
        "private var x": Double.self,
        "internal var z": Bool.self,
    ]
)
struct Example {
    
    init() {
        self = Self.allocate((
            y: 5,
            x: 4.3,
            z: true
        ))
    }
    
    func flipZ() {
        self.z.toggle()
    }
    
    func delete() {
        self.deallocate()
    }
}

// Usage
let example = Example()
// XCTAssertEqual(example.x, 4.3) // no access, x is private
XCTAssertEqual(example.y, 5)
XCTAssertEqual(example.z, true)
example.z = false
XCTAssertEqual(example.z, false)
func scoped(_ copiedRef: Example) {
    copiedRef.y = 100
}
XCTAssertEqual(example.y, 5)
scoped(example)
XCTAssertEqual(example.y, 100)
5 Likes

This package now supports static arrays!

@NRC(
    members: [
        "let before" : String.self,
        "var myArray": NRCStaticArray(Int.self, 10),
        "let after" : String.self,
    ]
)
struct ExampleStaticArray: SwiftNRCObject {
    
    init?(_ numbers: Int...) {
        guard numbers.count == Self.myArrayCount else {
            return nil
        }
        self = .allocate()
        self._force_set_before(to: "before string")
        for (i, number) in numbers.enumerated() {
            self.myArray[i] = number
        }
        self._force_set_after(to: "after string")
    }
    func delete() {
        self.deallocate()
    }
    
}

func arrayUsage() {
    let exampleStaticArray = ExampleStaticArray(9, 8, 7, 6, 5, 4, 3, 2, 1, 0)!
    for i in 0..<10 {
        XCTAssertEqual(exampleStaticArray.myArray[i], 9 - i)
        exampleStaticArray.myArray[i] = i
    }
    let pointerToFirstElement = exampleStaticArray.myArrayPointer
}

Class instances are deallocated when reference count drops to zero, when would NRC instances be deallocated?

It’s up to you. This is for manual memory management.

You call the private method “deallocate” to clean up

Great direction, I think it would be better if you can add some benchmarks showcasing benefits over ARC?

Here's my take.

Infrastructure (using dynamic member lookup):

@dynamicMemberLookup
struct Reference<Value> {
    var value: UnsafeMutablePointer<Value>!

    init(_ v: Value) {
        // 🤔 which is better this:
        withUnsafePointer(to: v) { p in
            value = UnsafeMutablePointer<Value>.allocate(capacity: 1)
            value.initialize(from: p, count: 1)
        }
        // or this?
        /*
        var v = v
        value = UnsafeMutablePointer<Value>.allocate(capacity: 1)
        value.initialize(from: &v, count: 1)
        */
    }
    
    mutating func deallocate() {
        value.deinitialize(count: 1)
        value.deallocate()
        value = nil
    }

    /* is this subscript needed??? 🤔
    subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
        value.pointee[keyPath: keyPath]
    }
    */
    
    subscript<T>(dynamicMember keyPath: WritableKeyPath<Value, T>) -> T {
        get {
            value.pointee[keyPath: keyPath]
        }
        nonmutating set {
            value.pointee[keyPath: keyPath] = newValue
        }
    }
}

Usage example:

struct ExampleType {
    var x: Int
    var y: Double
}

func foo(_ v: Reference<ExampleType>) {
    v.x = 456
}

var v = Reference(ExampleType(x: 123, y: 3.14))
print(v.x) // 123
foo(v)
print(v.x) // 456
print(v.y) // 3.14
v.deallocate()
print(v.x) // Swift runtime failure

It's quite concise.

Btw, which version of "init" implementation above is better performance wise, or are they equivalent? Is the "KeyPath" subscript version needed? (the sample compiles just fine without it).

My first iteration of this idea was very similar to your "Reference" type! The issue I had with it was that it led to a lot of extra code to deal with types.

struct ObjStorage {
    let i: Int
}
let myObject: Ref<ObjStorage> = .init(ObjStorage(x: 5))

func doSomething(with obj: Ref<ObjStorage>) { ... }

Compared to the macro version

@NRC(
    "let x" : Int.self
)
struct Obj {
    init(x: Int) {
        self.x = x
    }
}

let obj = Obj(x: 5)

func doSomething(with obj: Obj) { ... }

I have a system of objects right now in a performance critical section which I know do not need ARC, so this macro was my solution to getting rid of all the verbose wrapper types.

1 Like

The syntax overhead is small. I'd be more concerned about dynamic member lookup overhead of my version, that's likely to be much bigger than ARC overhead.

I think dynamic member has 0 overhead as it’s resolved at compile time. I personally find dynamic members to be an annoying Swift feature because the autocomplete doesn’t handle it well but it’s just personal preference

Dynamic member lookup is not resolved at compile time (except possibly as an optimization). That's what the "dynamic" in the feature name means.

2 Likes

I do not think that avoiding ARC is a big advantage here. You use classes if it fits the purpose, a good fit would be if you would like to work with references and you have a natural hierarchy of classes. I think that inheritance is kind of expensive, but in many cases you can attenuate this to some degree by declaring your classes final. From some tests that I made some time ago, the difference to working with structs is then still there, but this is in the range of maybe a 20 % performance hit (sorry I do not remember the exact numbers and I do have any code to share).

So when classes fit your problem space well and final (or in some cases private) classes are a good option, I think classes are fine and the performance is good, I see this as a good tradeoff then. I would not use any further “tricks” here. Else, you should use structs.

2 Likes

I think you're right that this is bad idea. However, I'm working on my Swift interpreter and need to avoid the unnecessary book keeping with native Swift classes to help performance out.

could ~Copyable replace the need for unsafe pointers?

1 Like

One thing I’ve noticed is that ~Copyable entirely prevents you from getting a pointer if you do happen to need one, which was both surprising and frustrating.

2 Likes

We’ll get to generic non-copyable types some day! Then we can have a safe OwningPointer<T> for when you do need the data to be on the heap.

5 Likes

I tried this benchmark with ARC on and off. To switch ARC off I'm using the following C helper:

extern void* (*_swift_retain)(void*);
extern void* (*_swift_release)(void*);

static void* noopRetain(void* object) {
    return object;
}

static void* noopRelease(void* object) {
    return object;
}

void disableARC(void) {
    _swift_retain = noopRetain;
    _swift_release = noopRelease;
}

The difference was about 5% (0.60 seconds with ARC vs 0.57 seconds with no ARC).


Interestingly, switching to my above dynamicMemberLookup implementation by making the following changes:

- class Node {
+ struct NodeRec {

+ typealias Node = Reference<NodeRec>

            - splited.equal = Node(x: x)
            + splited.equal = Reference(NodeRec(x: x))

reduced the time down to 0.22 sec.

It's obviously leaking memory, but so does the 0.57 seconds version that has ARC disabled.

The "dynamicMemberLookup" is not relevant, as even if I remove that (along with the subscript) and change the relevant placed from:

orig.left

to

orig.value.pointee.left

so it compiles again – the timing is the same, meaning that there's indeed a serious optimisation of dynamicMember lookups.


I can't yet explain the huge difference between 0.57 and 0.22.

3 Likes

Very interesting findings! Do I understand correctly that simply going from classes to manually allocated structs in the heap it went from 0.57 to 0.22? Maybe there's extra bookkeeping associated with classes other than retain/release?

This is definitely outside my scope of knowledge

Yes and it's puzzling me, I didn't expect so big difference.