mirror of https://github.com/cirruslabs/tart.git
tart pull: 284% faster pulls with default concurrency setting (#970)
* DiskV2: avoid allocating zero chunk on each zeroSkippingWrite() call * Increase hole granularity size from 64 KiB to 4 MiB * Fetcher: never write to disk, thanks to URLSessionDataDelegate
This commit is contained in:
parent
32ebc5bdbc
commit
31ab4218f7
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"originHash" : "6a15657d8cb1d3e2b447f31aff5b47d6a9655d2262e48ca76476ba525435269b",
|
"originHash" : "22b3726bc4e4c6e9c04ac97cb08a82967feb39960a93d2909768a16e11576748",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "antlr4",
|
"identity" : "antlr4",
|
||||||
|
|
@ -55,15 +55,6 @@
|
||||||
"version" : "1.5.0"
|
"version" : "1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-async-algorithms",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "main",
|
|
||||||
"revision" : "5c8bd186f48c16af0775972700626f0b74588278"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-atomics",
|
"identity" : "swift-atomics",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
@ -73,15 +64,6 @@
|
||||||
"version" : "1.2.0"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "swift-collections",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-collections.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
|
|
||||||
"version" : "1.1.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-log",
|
"identity" : "swift-log",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ let package = Package(
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
|
||||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||||
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
|
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
|
||||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||||
|
|
@ -29,7 +28,6 @@ let package = Package(
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "Dynamic", package: "Dynamic"),
|
.product(name: "Dynamic", package: "Dynamic"),
|
||||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import AsyncAlgorithms
|
|
||||||
|
|
||||||
fileprivate let urlSession = createURLSession()
|
fileprivate var urlSessionConfiguration: URLSessionConfiguration {
|
||||||
|
|
||||||
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
|
|
||||||
let progress: Progress
|
|
||||||
init(_ progress: Progress) throws {
|
|
||||||
self.progress = progress
|
|
||||||
}
|
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
|
|
||||||
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Fetcher {
|
|
||||||
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
|
||||||
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
|
|
||||||
|
|
||||||
if viaFile {
|
|
||||||
return try await fetchViaFile(request, delegate: delegate)
|
|
||||||
}
|
|
||||||
|
|
||||||
return try await fetchViaMemory(request, delegate: delegate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
|
||||||
|
|
||||||
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await dataCh.send(data)
|
|
||||||
|
|
||||||
dataCh.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (dataCh, response as! HTTPURLResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
|
||||||
|
|
||||||
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
|
|
||||||
|
|
||||||
// Acquire a handle to the downloaded file and then remove it.
|
|
||||||
//
|
|
||||||
// This keeps a working reference to that file, yet we don't
|
|
||||||
// have to deal with the cleanup any more.
|
|
||||||
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
|
||||||
await dataCh.send(mappedFile.subdata(in: chunk))
|
|
||||||
}
|
|
||||||
|
|
||||||
dataCh.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (dataCh, response as! HTTPURLResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func createURLSession() -> URLSession {
|
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
|
|
||||||
// Harbor expects a CSRF token to be present if the HTTP client
|
// Harbor expects a CSRF token to be present if the HTTP client
|
||||||
|
|
@ -76,5 +13,78 @@ fileprivate func createURLSession() -> URLSession {
|
||||||
// [2]: https://github.com/cirruslabs/tart/issues/295
|
// [2]: https://github.com/cirruslabs/tart/issues/295
|
||||||
config.httpShouldSetCookies = false
|
config.httpShouldSetCookies = false
|
||||||
|
|
||||||
return URLSession(configuration: config)
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fetcher {
|
||||||
|
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||||
|
let delegate = Delegate()
|
||||||
|
let session = URLSession(configuration: urlSessionConfiguration, delegate: delegate, delegateQueue: nil)
|
||||||
|
let task = session.dataTask(with: request)
|
||||||
|
|
||||||
|
let stream = AsyncThrowingStream<Data, Error> { continuation in
|
||||||
|
delegate.streamContinuation = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try await withCheckedThrowingContinuation { continuation in
|
||||||
|
delegate.responseContinuation = continuation
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (stream, response as! HTTPURLResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate class Delegate: NSObject, URLSessionDataDelegate {
|
||||||
|
var responseContinuation: CheckedContinuation<URLResponse, Error>?
|
||||||
|
var streamContinuation: AsyncThrowingStream<Data, Error>.Continuation?
|
||||||
|
|
||||||
|
private var buffer: Data = Data()
|
||||||
|
private let bufferFlushSize = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
|
didReceive response: URLResponse,
|
||||||
|
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||||
|
) {
|
||||||
|
// Soft-limit for the maximum buffer capacity
|
||||||
|
let capacity = min(response.expectedContentLength, Int64(bufferFlushSize))
|
||||||
|
|
||||||
|
// Pre-initialize buffer as we now know the capacity
|
||||||
|
buffer = Data(capacity: Int(capacity))
|
||||||
|
|
||||||
|
responseContinuation?.resume(returning: response)
|
||||||
|
completionHandler(.allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
|
didReceive data: Data
|
||||||
|
) {
|
||||||
|
buffer.append(data)
|
||||||
|
|
||||||
|
if buffer.count >= bufferFlushSize {
|
||||||
|
streamContinuation?.yield(buffer)
|
||||||
|
buffer.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didCompleteWithError error: Error?
|
||||||
|
) {
|
||||||
|
if !buffer.isEmpty {
|
||||||
|
streamContinuation?.yield(buffer)
|
||||||
|
buffer.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
streamContinuation?.finish(throwing: error)
|
||||||
|
} else {
|
||||||
|
streamContinuation?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,21 @@ class DiskV2: Disk {
|
||||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
private static let layerLimitBytes = 512 * 1024 * 1024
|
private static let layerLimitBytes = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
// A zero chunk for faster than byte-by-byte comparisons
|
||||||
|
//
|
||||||
|
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||||
|
// on the last block since it costs only 4 MiB of excess data per 512 MiB layer.
|
||||||
|
//
|
||||||
|
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Operation | time(1) result |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||||
|
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
private static let holeGranularityBytes = 4 * 1024 * 1024
|
||||||
|
private static let zeroChunk = Data(count: holeGranularityBytes)
|
||||||
|
|
||||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
|
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
|
||||||
|
|
||||||
|
|
@ -215,22 +230,6 @@ class DiskV2: Disk {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||||
let holeGranularityBytes = 64 * 1024
|
|
||||||
|
|
||||||
// A zero chunk for faster than byte-by-byte comparisons
|
|
||||||
//
|
|
||||||
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
|
||||||
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
|
|
||||||
//
|
|
||||||
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
|
||||||
// +--------------------------------------+---------------------------------------------------+
|
|
||||||
// | Operation | time(1) result |
|
|
||||||
// +--------------------------------------+---------------------------------------------------+
|
|
||||||
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
|
||||||
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
|
||||||
// +--------------------------------------+---------------------------------------------------+
|
|
||||||
let zeroChunk = Data(count: holeGranularityBytes)
|
|
||||||
|
|
||||||
var offset = offset
|
var offset = offset
|
||||||
|
|
||||||
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Algorithms
|
import Algorithms
|
||||||
import AsyncAlgorithms
|
|
||||||
|
|
||||||
enum RegistryError: Error {
|
enum RegistryError: Error {
|
||||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||||
|
|
@ -31,7 +30,7 @@ extension Data {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AsyncThrowingChannel<Data, Error> {
|
extension AsyncThrowingStream<Data, Error> {
|
||||||
func asData() async throws -> Data {
|
func asData() async throws -> Data {
|
||||||
var result = Data()
|
var result = Data()
|
||||||
|
|
||||||
|
|
@ -307,7 +306,7 @@ class Registry {
|
||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true,
|
doAuth: Bool = true,
|
||||||
viaFile: Bool = false
|
viaFile: Bool = false
|
||||||
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||||
|
|
@ -413,7 +412,7 @@ class Registry {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if doAuth {
|
if doAuth {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
import AsyncAlgorithms
|
|
||||||
import Semaphore
|
import Semaphore
|
||||||
|
|
||||||
struct UnsupportedRestoreImageError: Error {
|
struct UnsupportedRestoreImageError: Error {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue