177 lines
7.1 KiB
Swift
177 lines
7.1 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
#if os(macOS)
|
|
import ContainerizationArchive
|
|
import ContainerizationEXT4
|
|
import ContainerizationExtras
|
|
import ContainerizationOCI
|
|
import Foundation
|
|
import SystemPackage
|
|
import Testing
|
|
|
|
@testable import Containerization
|
|
|
|
/// Measures header scan overhead vs. full unpack time using real container images
|
|
/// pulled from a registry.
|
|
///
|
|
/// Run with:
|
|
/// ENABLE_TIMING_TESTS=1 swift test --filter ImageHeaderScanTimingTest
|
|
@Suite
|
|
struct ImageHeaderScanTimingTest {
|
|
private static let isEnabled = ProcessInfo.processInfo.environment["ENABLE_TIMING_TESTS"] != nil
|
|
|
|
let store: ImageStore
|
|
let dir: URL
|
|
let contentStore: ContentStore
|
|
|
|
init() throws {
|
|
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
let cs = try LocalContentStore(path: dir)
|
|
let store = try ImageStore(path: dir, contentStore: cs)
|
|
self.dir = dir
|
|
self.store = store
|
|
self.contentStore = cs
|
|
}
|
|
|
|
@Test(.enabled(if: ImageHeaderScanTimingTest.isEnabled))
|
|
func measureAlpineAndUbuntu() async throws {
|
|
defer { try? FileManager.default.removeItem(at: dir) }
|
|
|
|
let images: [(reference: String, label: String)] = [
|
|
("ghcr.io/linuxcontainers/alpine:3.20", "Alpine 3.20"),
|
|
("docker.io/library/ubuntu:24.04", "Ubuntu 24.04"),
|
|
]
|
|
|
|
for image in images {
|
|
print("\n==============================")
|
|
print("Image: \(image.label)")
|
|
print("==============================")
|
|
|
|
let img = try await store.pull(reference: image.reference, platform: .current)
|
|
let manifest = try await img.manifest(for: .current)
|
|
|
|
for (i, layer) in manifest.layers.enumerated() {
|
|
let content = try await img.getContent(digest: layer.digest)
|
|
let compression = compressionFilter(for: layer.mediaType)
|
|
let compressedSize = try FileManager.default.attributesOfItem(atPath: content.path.path)[.size] as? Int64 ?? 0
|
|
let label = "\(image.label) layer \(i + 1)/\(manifest.layers.count) (\(layer.mediaType), \(formatBytes(compressedSize)) compressed)"
|
|
try await measureOverhead(url: content.path, compression: compression, label: label)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func compressionFilter(for mediaType: String) -> ContainerizationArchive.Filter {
|
|
switch mediaType {
|
|
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
|
|
return .zstd
|
|
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
|
|
return .none
|
|
default:
|
|
return .gzip
|
|
}
|
|
}
|
|
|
|
private func measureOverhead(url: URL, compression: ContainerizationArchive.Filter, label: String) async throws {
|
|
let clock = ContinuousClock()
|
|
|
|
print("\n--- \(label) ---\n")
|
|
|
|
// For zstd, pre-decompress once (matching the production code path in EXT4Unpacker).
|
|
let scanFile: URL
|
|
let scanFilter: ContainerizationArchive.Filter
|
|
var decompressedFile: URL?
|
|
if compression == .zstd {
|
|
var decompressed: URL = url
|
|
let decompressDuration = try clock.measure {
|
|
decompressed = try ArchiveReader.decompressZstd(url)
|
|
}
|
|
scanFile = decompressed
|
|
scanFilter = .none
|
|
decompressedFile = decompressed
|
|
print(" Zstd decompress: \(decompressDuration)")
|
|
} else {
|
|
scanFile = url
|
|
scanFilter = compression
|
|
}
|
|
defer {
|
|
if let decompressedFile {
|
|
ArchiveReader.cleanUpDecompressedZstd(decompressedFile)
|
|
}
|
|
}
|
|
|
|
// 1. Header scan only
|
|
var scannedTotals: (size: Int64, items: Int) = (0, 0)
|
|
let scanDuration = try clock.measure {
|
|
scannedTotals = try EXT4.Formatter.scanArchiveHeaders(
|
|
format: .paxRestricted, filter: scanFilter, file: scanFile)
|
|
}
|
|
print(" Scanned total size: \(formatBytes(scannedTotals.size)) (\(scannedTotals.items) items)")
|
|
print(" Header scan: \(scanDuration)")
|
|
|
|
// 2. Full unpack without progress
|
|
let tempDir1 = FileManager.default.uniqueTemporaryDirectory()
|
|
let fsPath1 = FilePath(tempDir1.appendingPathComponent("no-progress.ext4.img", isDirectory: false))
|
|
defer { try? FileManager.default.removeItem(at: tempDir1) }
|
|
|
|
let unpackOnlyDuration = try await clock.measure {
|
|
let formatter = try EXT4.Formatter(fsPath1)
|
|
try await formatter.unpack(source: url, compression: compression)
|
|
try formatter.close()
|
|
}
|
|
print(" Unpack (no progress): \(unpackOnlyDuration)")
|
|
|
|
// 3. Full unpack with progress (includes header scan pass)
|
|
let tempDir2 = FileManager.default.uniqueTemporaryDirectory()
|
|
let fsPath2 = FilePath(tempDir2.appendingPathComponent("with-progress.ext4.img", isDirectory: false))
|
|
defer { try? FileManager.default.removeItem(at: tempDir2) }
|
|
|
|
let noopProgress: ProgressHandler = { _ in }
|
|
let withProgressDuration = try await clock.measure {
|
|
let formatter = try EXT4.Formatter(fsPath2)
|
|
try await formatter.unpack(source: url, compression: compression, progress: noopProgress)
|
|
try formatter.close()
|
|
}
|
|
print(" Unpack (w/ progress): \(withProgressDuration)")
|
|
|
|
// Summary
|
|
let scanMs = toMs(scanDuration)
|
|
let unpackMs = toMs(unpackOnlyDuration)
|
|
let withProgressMs = toMs(withProgressDuration)
|
|
let overheadMs = withProgressMs - unpackMs
|
|
let overheadPct = unpackMs > 0 ? (overheadMs / unpackMs) * 100 : 0
|
|
|
|
print("\n Summary:")
|
|
print(" Header scan alone: \(String(format: "%.1f", scanMs)) ms")
|
|
print(" Unpack only: \(String(format: "%.1f", unpackMs)) ms")
|
|
print(" Unpack + progress: \(String(format: "%.1f", withProgressMs)) ms")
|
|
print(" Overhead: \(String(format: "%.1f", overheadMs)) ms (\(String(format: "%.1f", overheadPct))%)")
|
|
}
|
|
|
|
private func toMs(_ d: Duration) -> Double {
|
|
let c = d.components
|
|
return Double(c.seconds) * 1000.0 + Double(c.attoseconds) / 1e15
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let mb = Double(bytes) / 1_048_576.0
|
|
return "\(String(format: "%.1f", mb)) MB"
|
|
}
|
|
}
|
|
#endif
|