Data race when concurrently calling C function using global variables

I've been using a binary written in C (pngnq-s9) to quantize PNG files to drastically reduce their size. Up to now, I've been calling the binary itself using Process/NSTask. Since multiple PNG files have to be processed, I've parallelized these tasks using a TaskGroup.

Now, I want to see if calling the underlying C function directly from Swift would be faster. I have written a very thin Swift wrapper that imports the C library and wraps calls to the imported global C function (pngnq()) that quantizes a PNG file. However, this function calls another that heavily relies on global variables (to be found here), causing data races and EXC_BAD_ACCESS.

Now, my question: is it possible to "isolate" or "prefix" calls to a C global function with global variables? I mean this in the sense that each call to the C function from my Swift wrapper would create its own context (thread? process?) and address space where the global variables are not in conflict with other threads? Anything short of rewriting the C library would be nice. Thanks in advance!

EDIT: in short, is it possible to create a distinct memory space (like a subprocess) here?

1 Like

You can call those C functions on the single thread/queue but that would undo any parallelisation you've done above via the TaskGroups.

It would be possible to spawn a new process for each PNG file, could be a bit heavy though.

The approach using the C binary directly does use Process/NSTask, so it's indeed spawning separate processes (and that's why they don't run into data races). However, are you saying there would be a way to spawn a process without calling an external executable or are you suggesting compiling the Swift wrapper as an executable and then calling it as a separate process?

The second option (calling a binary built around the Swift wrapper) would not be very efficient, and I can't sacrifice performance by undoing parallelization. The performance gains I measured when calling the Swift wrapper instead of the external C binary were minute anyway, so I think I'll need to rewrite the whole thing (in Swift though) if I want things to run on iOS, where Process/NSTask is not available.

I reread what you've said and it indeed looks that processes are unavoidable in your case unless of course you can tolerate processing all PNGs sequentially in your main app process.

You can try using "thumbnail extension" (a subclass of QLThumbnailProvider) – that runs in a separate process and is availble on both macOS and iOS. A bit roundabout way but could do the trick.

The short answer is no.

In addition to spawning off a process, you can also utilise an XPC service (on macOS only.)

Just in case you are not familiar with it, an XPC service also runs in its own process and is easier to communicate with.

1 Like

I think in the end it’ll be easier to rewrite this using Swift and the Accelerate framework (the original C implementation is quite bare-bones), which might lead to even more optimizations!

4 Likes

Rewriting it in Swift would be great for the Swift community, but looks like it'd be quite a bit of work. An easier alternative is to fix the C library - e.g. move all those globals into a context structure that the caller provides to the library.

Are there other PNG libraries you could use, though? That one hasn't been touched in over four years. Perhaps there's a more active equivalent, that might already have removed the globals?

2 Likes

AFAIK pngnq-s9 is a descendant (or cousin?) of pngquant and the related libimagequant, which are still actively maintained. They used to be written in C, but now are written in Rust, with a C interface. While usable from a Swift program, I don’t think could use the Rust version (pre-compiled or source) within my Swift Package.

But from brief testing that I had actually already done in the past, mistakenly thinking that pngquant was less powerful, it’s actually way more efficient.

I like this approach.

Make a global variable:

typedef struct {
} G;

G gv;
G* g = &gv;

Then move the first global variable into it:

// static int search_cache[SEARCH_CACHE_SIZE];
...
typedef struct {
    int search_cache[SEARCH_CACHE_SIZE];
} G;

and change the code to use g->search_cache instead of search_cache – it should still compile and work as before. Repeat for all global variables. After the end of this exercise you'll only have a single global variable g. Then comment that global variable out and whenever you need g pass it as a parameter.

Note there could be static variables inside functions – those should be treated as global variables (and if there are name collisions between them resolve those collisions e.g. by prefixing those names appropriately.

you could try using @taylorswift’s pure swift PNG library, it might fulfill your needs to reduce file size and be fast

1 Like

I wonder how the following sample code compares (speed wise) to what you are doing now. Ignore the poor quantisation quality. It works in both iOS and macOS and should be thread safe (parallelizable).

Sample code that uses platform PNG conversion via ImageIO
extension Data {
    // works on Apple platforms like macOS and iOS
    func quantizedImageData(quality /*TODO*/: Int = 0) -> Data {
        let imageData = self
        let source = CGImageSourceCreateWithData(imageData as CFData, nil)!
        let type = CGImageSourceGetType(source)!
        let count = CGImageSourceGetCount(source)
        precondition(count == 1) // TODO: suport multi images later
        let image = CGImageSourceCreateImageAtIndex(source, 0, nil)!
        let bitmapInfo = image.bitmapInfo
        let alpha = image.alphaInfo
        let newAlphaInfo: CGImageAlphaInfo = switch alpha {
            case .last: .noneSkipLast // should it be premultipliedLast ?
            default: fatalError("TODO")
        }
        let (width, height, stride) = (image.width, image.height, image.bytesPerRow)
        let newBitmapInfo = CGBitmapInfo(rawValue: (bitmapInfo.rawValue & ~CGBitmapInfo.alphaInfoMask.rawValue) | newAlphaInfo.rawValue)
        
        let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: image.bitsPerComponent, bytesPerRow: stride, space: image.colorSpace!, bitmapInfo: newBitmapInfo.rawValue)!
        context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
        
        let p = context.data!.assumingMemoryBound(to: UInt32.self)
        
        precondition(image.bitsPerComponent == 8 && stride >= width*4, "TODO other formats")
        let pixelStride = stride/4
        
        // TODO: do something better (dithering, halftoning, accelerate, CoreImage, etc)
        for y in 0 ..< height {
            let line = y * pixelStride
            for x in 0 ..< width {
                p[line + x] &= 0xFFE0E0E0 // some simple colour quantization
            }
        }
        
        let newImage = context.makeImage()!
        let newCFData = CFDataCreateMutable(kCFAllocatorDefault, 0)!
        let destination = CGImageDestinationCreateWithData(newCFData, type, 1, nil)!
        CGImageDestinationAddImage(destination, newImage, nil)
        let done = CGImageDestinationFinalize(destination)
        precondition(done)
        let newData = newCFData as CFData as Data
        return newData
    }
}

I checked it out and it's mostly a replacement for libpng, so reading and writing PNGs (with compression), not quantization. The API also feels very un-Swifty to me, even though it's 100% Swift.