
Short answer: rent a Figment M-series Mac (from $95/month, chip/RAM/disk confirmed in the quote) and run the Swift harness below. It applies a Core Image or Metal compute filter to a reference image, reads the output pixels back, hashes them with SHA-256, and prints per-run GPU execution time computed from gpuEndTime − gpuStartTime on MTLCommandBuffer (Apple developer documentation — gpuStartTime). Metal 4 on Mac requires Apple silicon (M1 or later) per developer.apple.com/metal, so GPU-dependent filter validation needs the physical M-series plan. The $29 virtual Intel macOS runs on Linux/KVM with no GPU acceleration and cannot stand in for a real device GPU.
The iOS Simulator and a virtual macOS render through whatever GPU the host exposes — the Simulator uses your Mac's GPU, and the $29 Intel VM has no GPU acceleration at all. Four concrete gaps come up repeatedly in filter work:
If a filter passes in the Simulator and fails on-device, the first signal arrived in the wrong place.
Access to a physical Figment Mac — login path, screen share, and any remote shell — is set in the quote; the $29 VM is reached from a browser console. Once you are on the M-series machine:
.xip you have already downloaded. Xcode 26 and earlier run on supported Intel Macs; Xcode 27 is Apple silicon only — see Apple's porting guide at developer.apple.com/documentation/apple-silicon/porting-your-macos-apps-to-apple-silicon. Check the exact Xcode/macOS pair for your target before you commit.system_profiler SPDisplaysDataType or by reading the first line the harness prints — device.name is what you measured against.The rented M-series Mac is itself the Metal device under test. No tethered iPhone or iPad is required for a macOS validation pass.
The program below loads an image, applies a Core Image filter through a Metal-backed CIContext, reads the output texture back, hashes the bytes with SHA-256, and prints the median GPU execution time across --repeats N runs. It uses MTLCommandBuffer.gpuStartTime and gpuEndTime, which Apple documents as host times (relative to system mach time) captured when the GPU starts and finishes command-buffer execution; their difference is the GPU running time for that submission (gpuStartTime reference). Treat it as an unexecuted example: compile it with the Swift version your installed Xcode ships, and inspect the output of a controlled baseline before accepting it as the reference.
// MetalFilterValidator.swift
// Build: swiftc MetalFilterValidator.swift -o MetalFilterValidator
// Run: ./MetalFilterValidator <input.png> <expected_sha256> [--repeats N]
import Foundation
import Metal
import CoreImage
import CoreImage.CIFilterBuiltins
import CoreGraphics
import CryptoKit
let args = CommandLine.arguments
guard args.count >= 3 else {
FileHandle.standardError.write(Data(
"Usage: MetalFilterValidator <input.png> <expected_sha256> [--repeats N]\n".utf8))
exit(2)
}
let inputPath = args[1]
let expectedHash = args[2].lowercased()
var repeats = 10
if let i = args.firstIndex(of: "--repeats"), i + 1 < args.count,
let n = Int(args[i + 1]), n > 0 { repeats = n }
guard let device = MTLCreateSystemDefaultDevice(),
let queue = device.makeCommandQueue() else {
FileHandle.standardError.write(Data("ERROR: No Metal device/queue.\n".utf8))
exit(1)
}
print("GPU: \(device.name)")
guard let input = CIImage(contentsOf: URL(fileURLWithPath: inputPath)) else {
FileHandle.standardError.write(Data("ERROR: Could not load \(inputPath)\n".utf8))
exit(1)
}
// Replace with the filter under test.
let f = CIFilter.gaussianBlur()
f.inputImage = input
f.radius = 8.0
guard let output = f.outputImage?.cropped(to: input.extent) else {
FileHandle.standardError.write(Data("ERROR: Filter produced no output.\n".utf8))
exit(1)
}
let bounds = input.extent.integral
let width = Int(bounds.width)
let height = Int(bounds.height)
let desc = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .rgba8Unorm, width: width, height: height, mipmapped: false)
desc.usage = [.renderTarget, .shaderRead]
desc.storageMode = .shared
guard let texture = device.makeTexture(descriptor: desc) else {
FileHandle.standardError.write(Data("ERROR: Texture allocation failed.\n".utf8))
exit(1)
}
guard let outputSpace = CGColorSpace(name: CGColorSpace.sRGB),
let workingSpace = CGColorSpace(name: CGColorSpace.extendedLinearSRGB) else { exit(1) }
let ciContext = CIContext(mtlDevice: device, options: [.workingColorSpace: workingSpace])
var timings: [Double] = []
// One warm-up pass (not recorded), then `repeats` measured passes.
for run in 0...repeats {
guard let cb = queue.makeCommandBuffer() else { exit(1) }
ciContext.render(output, to: texture, commandBuffer: cb,
bounds: bounds, colorSpace: outputSpace)
cb.commit()
cb.waitUntilCompleted()
if let err = cb.error {
FileHandle.standardError.write(Data("ERROR: Command buffer failed: \(err)\n".utf8))
exit(1)
}
let elapsed = cb.gpuEndTime - cb.gpuStartTime
guard cb.gpuStartTime > 0, elapsed.isFinite, elapsed > 0 else {
FileHandle.standardError.write(Data("ERROR: GPU timing unavailable.\n".utf8))
exit(1)
}
if run > 0 { timings.append(elapsed) }
}
let bytesPerRow = 4 * width
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
texture.getBytes(&pixels, bytesPerRow: bytesPerRow,
from: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0)
let actualHash = SHA256.hash(data: Data(pixels))
.map { String(format: "%02x", $0) }.joined()
timings.sort()
let median = timings[timings.count / 2]
print(String(format: "GPU median over %d runs: %.4f ms", timings.count, median * 1000))
print("Expected: \(expectedHash)")
print("Actual : \(actualHash)")
if actualHash == expectedHash { print("PASS"); exit(0) }
FileHandle.standardError.write(Data("FAIL: pixel output does not match reference.\n".utf8))
exit(1)
Establishing the reference hash. Run the harness once on the configuration you trust — typically your workstation — capture the printed Actual value, and commit it to the test fixture alongside the input image. Pass that stored hash as the second argument on the rented Mac. A mismatch flags a difference to investigate. Floating-point or color-management differences across hardware and OS releases can be acceptable; a hash alone does not distinguish them from a defect. For cross-device acceptance, also compare pixels with an explicit tolerance and inspect the difference image. Keep the repeat loop inside the harness. Separate process runs change cache and initialization conditions. Keep those conditions explicit even when the reported timing covers only the GPU command buffer. If you need a smaller worksheet, set --repeats 5 and record the reported median; extend the example to print individual timings if you need to inspect their spread.
Treat the green PASS as evidence only when all of these are true:
device.name in the output matches the chip generation you care about. An M2 median is not a stand-in for an M4 Pro budget..gaussianBlur() with another Core Image filter requires a reviewed reference. A raw Metal compute pipeline needs its own encoder implementation; it is not a drop-in CIFilter replacement.swift --version and xcodebuild -version are recorded alongside the hash.Chip generation and RAM are not pinned at the $95 entry price. Confirm the chip in the quote based on your shader's threadgroup memory ceiling and any Metal feature-set dependency.
| Workload | Figment plan | Confirm in the quote |
|---|---|---|
| Single-pass Core Image, 1080p | M-series from $95/month | Chip generation, RAM, disk, availability |
| Multi-pass Metal compute, large tiles | M-series from $95/month | Threadgroup memory of the specific chip |
| MetalFX upscaling or denoising | M-series from $95/month | M1 or later for Metal 4 features |
| Scheduled filter validation and CI on one host | Managed Build Mac $249/month | One M-series Mac, CI setup, backups, monitoring, 30 min monthly support |
The $29 virtual Intel macOS is useful for Intel toolchain work but has no GPU acceleration and cannot validate Metal on Apple silicon; its access path is a browser console, not direct inbound SSH. The i3 Mac at $49/month and M-series from $95/month collect requests until supply is confirmed, so treat the price as an entry point, not a stock promise. There is no published Figment free trial or uptime SLA — agree an evaluation, exact spec and the physical access path in the quote before you commit.
For an external sanity check, CloudMac lists an M4 Mac mini (16 GB / 256 GB) at $99.99/month on cloudmac.org/pricing and an advertised annual rate of $999/year — $83.25 effective monthly, or about $141 less over twelve months than 12 × $95 ($1,140) at Figment's entry price. This is an entry-price comparison, not equal hardware: $95 at Figment is the M-series entry, not a fixed M4/16 GB. If the Figment quote comes back as M4/16 GB the CloudMac number is a useful anchor; if it comes back with a larger chip, more RAM, or the Managed Build Mac package, the comparison moves. CloudMac's 512 GB and 1 TB rows currently show "Fully reserved — next allocation not scheduled," so availability on that side is not blanket either.
Ready to validate on real Apple silicon? Describe the chip, RAM, and disk you need at Figment Mac Hosting and start the order at /mac-hosting/start.
Related reading: