feat(gpu): MLX-batched pairs bootstrap with bit-identical Philox streams
- mlx-swift dependency in ZQGPU (the only module allowed to import MLX) - Philox4x32-10 as vectorized MLX uint64 ops with the reference key schedule: GPU resample indices are bit-identical to the CPU generator (asserted per replicate) — the §5 reproducibility contract - batched gather + float32 cross-products on GPU, float64 Cholesky solves on CPU (§5 hybrid); chunked to the memory budget - conditioning: predictors standardized by fixed full-sample mean/scale (exact reparametrization) before forming X'X — raw data with an intercept lost 2-3 digits in float32 - MLX workaround: batched GEMM mis-accumulates small k x k outputs at batch >= 2 (~6e-4 rel, stride/fusion-independent); X'X is built one column at a time through the exact (count,k,n)@(count,n,1) path, with regression tests pinning the behavior - planner: gpuAvailable detected from actual metallib presence (SwiftPM CLI cannot compile Metal shaders - swift test skips GPU suites, the xcodebuild-built app and tests get the GPU); reps >= 500 dispatches bootstrap to .gpu - engine: backend-aware bootstrap, drops singular resamples, output titled 'GPU batched' with gpu scalar - 68 tests green under xcodebuild (66 + GPU suites under swift test skip) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 7 changed files with +699 and −49
modified
MetrikaKit/Package.resolved
+19 −1
@@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | − "originHash" : "e4ae512a67bec7ae66f2abd23f1e87240c39d28811734cdfafc7a91e4323f6db", | |
| 2 | + "originHash" : "c4143a21eaf19f86159743269f92608859217bba0735131f48d3b12086f2b0c2", | |
| 3 | 3 | "pins" : [ |
| 4 | 4 | { |
| 5 | 5 | "identity" : "duckdb-swift", |
@@ -10,6 +10,15 @@ | ||
| 10 | 10 | "version" : "1.1.3" |
| 11 | 11 | } |
| 12 | 12 | }, |
| 13 | + { | |
| 14 | + "identity" : "mlx-swift", | |
| 15 | + "kind" : "remoteSourceControl", | |
| 16 | + "location" : "https://github.com/ml-explore/mlx-swift", | |
| 17 | + "state" : { | |
| 18 | + "revision" : "0bb916c67f4b9e5c682cbe02a42c701c93ab5021", | |
| 19 | + "version" : "0.31.6" | |
| 20 | + } | |
| 21 | + }, | |
| 13 | 22 | { |
| 14 | 23 | "identity" : "swift-argument-parser", |
| 15 | 24 | "kind" : "remoteSourceControl", |
@@ -27,6 +36,15 @@ | ||
| 27 | 36 | "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", |
| 28 | 37 | "version" : "1.6.0" |
| 29 | 38 | } |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "identity" : "swift-numerics", | |
| 42 | + "kind" : "remoteSourceControl", | |
| 43 | + "location" : "https://github.com/apple/swift-numerics", | |
| 44 | + "state" : { | |
| 45 | + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", | |
| 46 | + "version" : "1.1.1" | |
| 47 | + } | |
| 30 | 48 | } |
| 31 | 49 | ], |
| 32 | 50 | "version" : 3 |
modified
MetrikaKit/Package.swift
+5 −1
@@ -31,6 +31,7 @@ let package = Package( | ||
| 31 | 31 | .package(url: "https://github.com/duckdb/duckdb-swift", from: "1.0.0"), |
| 32 | 32 | .package(url: "https://github.com/apple/swift-collections", from: "1.1.0"), |
| 33 | 33 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), |
| 34 | + .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"), | |
| 34 | 35 | ], |
| 35 | 36 | targets: [ |
| 36 | 37 | .target( |
@@ -52,7 +53,10 @@ let package = Package( | ||
| 52 | 53 | ), |
| 53 | 54 | .target( |
| 54 | 55 | name: "ZQGPU", |
| 55 | − dependencies: ["ZQData"], | |
| 56 | + dependencies: [ | |
| 57 | + "ZQData", | |
| 58 | + .product(name: "MLX", package: "mlx-swift"), | |
| 59 | + ], | |
| 56 | 60 | swiftSettings: strictConcurrency |
| 57 | 61 | ), |
| 58 | 62 | .target( |
modified
MetrikaKit/Sources/ZQEngine/Session.swift
+81 −47
@@ -35,7 +35,7 @@ public actor ZQSession { | ||
| 35 | 35 | |
| 36 | 36 | private let store: ZQDataStore |
| 37 | 37 | private let parser = ZQCommandParser() |
| 38 | − private let planner = ZQPlanner() | |
| 38 | + private let planner = ZQPlanner(gpuAvailable: ZQGPUBootstrap.isAvailable) | |
| 39 | 39 | private let scriptCommands: [String: ZQScriptCommand] |
| 40 | 40 | private var logFileURL: URL? |
| 41 | 41 | private var scriptDepth = 0 |
@@ -98,10 +98,12 @@ public actor ZQSession { | ||
| 98 | 98 | // User script commands shadow nothing built-in; check after parse |
| 99 | 99 | // failure would be better UX, but the verb table already resolved. |
| 100 | 100 | let plan = planner.plan(command, rowCount: frame.rowCount) |
| 101 | − return try await dispatch(plan.command) | |
| 101 | + return try await dispatch(plan.command, backend: plan.backend) | |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | − private func dispatch(_ command: ZQCommand) async throws -> ZQResult { | |
| 104 | + private func dispatch( | |
| 105 | + _ command: ZQCommand, backend: ZQBackend = .cpu | |
| 106 | + ) async throws -> ZQResult { | |
| 105 | 107 | if let script = scriptCommands[command.verb] { |
| 106 | 108 | guard scriptDepth < 8 else { |
| 107 | 109 | throw ZQEngineError("user command recursion too deep") |
@@ -132,7 +134,7 @@ public actor ZQSession { | ||
| 132 | 134 | case "set": return try handleSet(command) |
| 133 | 135 | case "xtset": return try handleXTSet(command) |
| 134 | 136 | case "display": return try handleDisplay(command) |
| 135 | − case "bootstrap": return try await handleBootstrap(command) | |
| 137 | + case "bootstrap": return try await handleBootstrap(command, backend: backend) | |
| 136 | 138 | case "graph": return try handleGraph(command) |
| 137 | 139 | case "log": return try handleLog(command) |
| 138 | 140 | default: |
@@ -1010,7 +1012,9 @@ public actor ZQSession { | ||
| 1010 | 1012 | |
| 1011 | 1013 | // MARK: - Bootstrap prefix |
| 1012 | 1014 | |
| 1013 | − private func handleBootstrap(_ command: ZQCommand) async throws -> ZQResult { | |
| 1015 | + private func handleBootstrap( | |
| 1016 | + _ command: ZQCommand, backend: ZQBackend | |
| 1017 | + ) async throws -> ZQResult { | |
| 1014 | 1018 | guard let body = command.body else { |
| 1015 | 1019 | throw ZQEngineError("bootstrap: syntax is 'bootstrap, reps(#): command'") |
| 1016 | 1020 | } |
@@ -1045,57 +1049,83 @@ public actor ZQSession { | ||
| 1045 | 1049 | |
| 1046 | 1050 | // Pairs bootstrap: replicate r regenerates its indices from the |
| 1047 | 1051 | // Philox counter stream — deterministic and order-independent, so |
| 1048 | − // replicates can be computed in parallel chunks. | |
| 1049 | − let chunkCount = min( | |
| 1050 | − max(1, ProcessInfo.processInfo.activeProcessorCount), reps | |
| 1051 | − ) | |
| 1052 | − let chunkSize = (reps + chunkCount - 1) / chunkCount | |
| 1053 | − let draws: [[Double]] = try await withThrowingTaskGroup( | |
| 1054 | − of: [(Int, [Double])].self | |
| 1055 | − ) { group in | |
| 1056 | − for chunk in 0..<chunkCount { | |
| 1057 | − let lower = chunk * chunkSize | |
| 1058 | − let upper = min(reps, lower + chunkSize) | |
| 1059 | − guard lower < upper else { continue } | |
| 1060 | − group.addTask { | |
| 1061 | − var results: [(Int, [Double])] = [] | |
| 1062 | − results.reserveCapacity(upper - lower) | |
| 1063 | − for replicate in lower..<upper { | |
| 1064 | − let indices = ZQResampling.pairsBootstrapIndices( | |
| 1065 | − replicate: replicate, sampleSize: n, generator: generator | |
| 1066 | − ) | |
| 1067 | − let resampledY = indices.map { y[$0] } | |
| 1068 | − let resampledPredictors = predictors.map { column in | |
| 1069 | − (name: column.name, values: indices.map { column.values[$0] }) | |
| 1052 | + // replicates can run in parallel CPU chunks or as GPU batches with | |
| 1053 | + // identical resamples. | |
| 1054 | + let allDraws: [[Double]] | |
| 1055 | + if backend == .gpu { | |
| 1056 | + allDraws = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 1057 | + y: y, | |
| 1058 | + predictors: predictors, | |
| 1059 | + includeConstant: includeConstant, | |
| 1060 | + replicates: reps, | |
| 1061 | + seed: seed | |
| 1062 | + ) | |
| 1063 | + } else { | |
| 1064 | + allDraws = try await withThrowingTaskGroup( | |
| 1065 | + of: [(Int, [Double])].self | |
| 1066 | + ) { group in | |
| 1067 | + let chunkCount = min( | |
| 1068 | + max(1, ProcessInfo.processInfo.activeProcessorCount), reps | |
| 1069 | + ) | |
| 1070 | + let chunkSize = (reps + chunkCount - 1) / chunkCount | |
| 1071 | + for chunk in 0..<chunkCount { | |
| 1072 | + let lower = chunk * chunkSize | |
| 1073 | + let upper = min(reps, lower + chunkSize) | |
| 1074 | + guard lower < upper else { continue } | |
| 1075 | + group.addTask { | |
| 1076 | + var results: [(Int, [Double])] = [] | |
| 1077 | + results.reserveCapacity(upper - lower) | |
| 1078 | + for replicate in lower..<upper { | |
| 1079 | + let indices = ZQResampling.pairsBootstrapIndices( | |
| 1080 | + replicate: replicate, sampleSize: n, generator: generator | |
| 1081 | + ) | |
| 1082 | + let resampledY = indices.map { y[$0] } | |
| 1083 | + let resampledPredictors = predictors.map { column in | |
| 1084 | + (name: column.name, values: indices.map { column.values[$0] }) | |
| 1085 | + } | |
| 1086 | + let fit = try ZQOLS.fit( | |
| 1087 | + y: resampledY, | |
| 1088 | + predictors: resampledPredictors, | |
| 1089 | + includeConstant: includeConstant, | |
| 1090 | + variance: .classical | |
| 1091 | + ) | |
| 1092 | + results.append((replicate, fit.coefficients.map(\.estimate))) | |
| 1070 | 1093 | } |
| 1071 | − let fit = try ZQOLS.fit( | |
| 1072 | − y: resampledY, | |
| 1073 | − predictors: resampledPredictors, | |
| 1074 | − includeConstant: includeConstant, | |
| 1075 | − variance: .classical | |
| 1076 | − ) | |
| 1077 | − results.append((replicate, fit.coefficients.map(\.estimate))) | |
| 1094 | + return results | |
| 1078 | 1095 | } |
| 1079 | − return results | |
| 1080 | 1096 | } |
| 1081 | − } | |
| 1082 | − var collected = [[Double]](repeating: [], count: reps) | |
| 1083 | − for try await chunkResults in group { | |
| 1084 | − for (replicate, estimates) in chunkResults { | |
| 1085 | − collected[replicate] = estimates | |
| 1097 | + var collected = [[Double]](repeating: [], count: reps) | |
| 1098 | + for try await chunkResults in group { | |
| 1099 | + for (replicate, estimates) in chunkResults { | |
| 1100 | + collected[replicate] = estimates | |
| 1101 | + } | |
| 1086 | 1102 | } |
| 1103 | + return collected | |
| 1087 | 1104 | } |
| 1088 | − return collected | |
| 1105 | + } | |
| 1106 | + | |
| 1107 | + // Singular resamples (NaN rows from the GPU path) are dropped, | |
| 1108 | + // Stata-style. | |
| 1109 | + let draws = allDraws.filter { row in !row.contains(where: \.isNaN) } | |
| 1110 | + let completed = draws.count | |
| 1111 | + guard completed >= 2 else { | |
| 1112 | + throw ZQEngineError("bootstrap: too many failed replicates") | |
| 1089 | 1113 | } |
| 1090 | 1114 | |
| 1091 | 1115 | // Bootstrap standard errors: sd of replicate estimates. |
| 1116 | + let title = backend == .gpu | |
| 1117 | + ? "Bootstrap results (pairs, GPU batched)" | |
| 1118 | + : "Bootstrap results (pairs)" | |
| 1092 | 1119 | var lines = [ |
| 1093 | − "Bootstrap results (pairs) Replications = " + | |
| 1094 | − TableFormatter.pad("\(reps)", 10), | |
| 1120 | + TableFormatter.pad(title, 46, right: false) + "Replications = " + | |
| 1121 | + TableFormatter.pad("\(completed)", 10), | |
| 1095 | 1122 | " Number of obs = " + |
| 1096 | 1123 | TableFormatter.pad("\(n)", 10), |
| 1097 | − "", | |
| 1098 | 1124 | ] |
| 1125 | + if completed < reps { | |
| 1126 | + lines.append("(\(reps - completed) replicates dropped: singular resample)") | |
| 1127 | + } | |
| 1128 | + lines.append("") | |
| 1099 | 1129 | let widths = [12, 12, 12, 8, 8, 22] |
| 1100 | 1130 | lines.append( |
| 1101 | 1131 | TableFormatter.pad(sample.responseName, widths[0]) + " | " + |
@@ -1112,12 +1142,16 @@ public actor ZQSession { | ||
| 1112 | 1142 | ) |
| 1113 | 1143 | lines.append(TableFormatter.rule(widths)) |
| 1114 | 1144 | |
| 1115 | − var scalars: [String: Double] = ["reps": Double(reps), "N": Double(n)] | |
| 1145 | + var scalars: [String: Double] = [ | |
| 1146 | + "reps": Double(completed), | |
| 1147 | + "N": Double(n), | |
| 1148 | + "gpu": backend == .gpu ? 1 : 0, | |
| 1149 | + ] | |
| 1116 | 1150 | for j in 0..<k { |
| 1117 | 1151 | let estimates = draws.map { $0[j] } |
| 1118 | − let mean = estimates.reduce(0, +) / Double(reps) | |
| 1152 | + let mean = estimates.reduce(0, +) / Double(completed) | |
| 1119 | 1153 | let variance = estimates.reduce(0) { $0 + ($1 - mean) * ($1 - mean) } |
| 1120 | − / Double(reps - 1) | |
| 1154 | + / Double(completed - 1) | |
| 1121 | 1155 | let se = variance.squareRoot() |
| 1122 | 1156 | let observed = point.coefficients[j].estimate |
| 1123 | 1157 | let z = observed / se |
added
MetrikaKit/Sources/ZQGPU/CholeskySolver.swift
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +// | |
| 2 | +// CholeskySolver.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +/// Plain-Swift Cholesky solve for the k×k normal equations of the GPU | |
| 11 | +/// bootstrap. k is the regressor count (single digits in practice), so a | |
| 12 | +/// dependency-free O(k³) routine beats reaching for LAPACK — and ZQStats | |
| 13 | +/// is the only module allowed to import Accelerate (CLAUDE.md §3). | |
| 14 | +enum CholeskySolver { | |
| 15 | + struct SingularMatrix: Error {} | |
| 16 | + | |
| 17 | + /// Solves A·x = b for symmetric positive-definite A (column-major, | |
| 18 | + /// k×k). Throws on non-positive-definite input. | |
| 19 | + static func solve(_ a: [Double], k: Int, rhs: [Double]) throws -> [Double] { | |
| 20 | + precondition(a.count == k * k && rhs.count == k) | |
| 21 | + | |
| 22 | + // Lower-triangular factor L with A = L·Lᵀ. | |
| 23 | + var l = [Double](repeating: 0, count: k * k) | |
| 24 | + for j in 0..<k { | |
| 25 | + var diagonal = a[j * k + j] | |
| 26 | + for p in 0..<j { | |
| 27 | + diagonal -= l[p * k + j] * l[p * k + j] | |
| 28 | + } | |
| 29 | + guard diagonal > 0 else { throw SingularMatrix() } | |
| 30 | + let root = diagonal.squareRoot() | |
| 31 | + l[j * k + j] = root | |
| 32 | + for i in (j + 1)..<k { | |
| 33 | + var value = a[j * k + i] | |
| 34 | + for p in 0..<j { | |
| 35 | + value -= l[p * k + i] * l[p * k + j] | |
| 36 | + } | |
| 37 | + l[j * k + i] = value / root | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + // Forward substitution L·z = b. | |
| 42 | + var z = [Double](repeating: 0, count: k) | |
| 43 | + for i in 0..<k { | |
| 44 | + var value = rhs[i] | |
| 45 | + for p in 0..<i { | |
| 46 | + value -= l[p * k + i] * z[p] | |
| 47 | + } | |
| 48 | + z[i] = value / l[i * k + i] | |
| 49 | + } | |
| 50 | + | |
| 51 | + // Back substitution Lᵀ·x = z. | |
| 52 | + var x = [Double](repeating: 0, count: k) | |
| 53 | + for i in stride(from: k - 1, through: 0, by: -1) { | |
| 54 | + var value = z[i] | |
| 55 | + for p in (i + 1)..<k { | |
| 56 | + value -= l[i * k + p] * x[p] | |
| 57 | + } | |
| 58 | + x[i] = value / l[i * k + i] | |
| 59 | + } | |
| 60 | + return x | |
| 61 | + } | |
| 62 | +} | |
added
MetrikaKit/Sources/ZQGPU/MLXBootstrap.swift
+340 −0
@@ -0,0 +1,340 @@ | ||
| 1 | +// | |
| 2 | +// MLXBootstrap.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import Foundation | |
| 11 | +import MLX | |
| 12 | + | |
| 13 | +/// GPU-batched pairs bootstrap (CLAUDE.md §5). | |
| 14 | +/// | |
| 15 | +/// Resample indices come from Philox4x32-10 evaluated as vectorized MLX | |
| 16 | +/// ops with the same key schedule as the CPU reference, so GPU and CPU | |
| 17 | +/// streams are bit-identical for a given seed — the reproducibility | |
| 18 | +/// release blocker. The heavy O(reps·n·k²) work (gather + cross-products) | |
| 19 | +/// runs on the GPU in float32; the tiny O(reps·k³) normal-equation solves | |
| 20 | +/// run on the CPU in float64 (§5 hybrid). | |
| 21 | +/// | |
| 22 | +/// Documented tolerance vs the CPU float64 path: coefficient draws agree | |
| 23 | +/// to ~1e-5 relative for well-conditioned designs (float32 gather sums). | |
| 24 | +public enum ZQGPUBootstrap { | |
| 25 | + | |
| 26 | + /// Whether the Metal-backed MLX device is usable in this process. | |
| 27 | + /// | |
| 28 | + /// SwiftPM's command-line build cannot compile Metal shaders, so under | |
| 29 | + /// `swift build`/`swift test` the mlx metallib does not exist and any | |
| 30 | + /// MLX op would abort. Mirror the C++ loader's search (colocated, | |
| 31 | + /// main-bundle Cmlx resource bundle, all loaded bundles) and report | |
| 32 | + /// GPU availability only when a metallib is actually present — | |
| 33 | + /// xcodebuild-built apps and tests have it, CLI builds fall back to | |
| 34 | + /// the CPU path via the planner. | |
| 35 | + public static let isAvailable: Bool = { | |
| 36 | + #if arch(arm64) | |
| 37 | + var roots: [URL] = [] | |
| 38 | + if let url = Bundle.main.resourceURL { roots.append(url) } | |
| 39 | + roots.append(Bundle.main.bundleURL) | |
| 40 | + if let executable = Bundle.main.executableURL { | |
| 41 | + roots.append(executable.deletingLastPathComponent()) | |
| 42 | + } | |
| 43 | + for bundle in Bundle.allBundles { | |
| 44 | + if let url = bundle.resourceURL { roots.append(url) } | |
| 45 | + } | |
| 46 | + let candidates = roots.flatMap { root in | |
| 47 | + [ | |
| 48 | + root.appendingPathComponent("mlx.metallib"), | |
| 49 | + root.appendingPathComponent("default.metallib"), | |
| 50 | + root.appendingPathComponent( | |
| 51 | + "mlx-swift_Cmlx.bundle/Contents/Resources/default.metallib" | |
| 52 | + ), | |
| 53 | + ] | |
| 54 | + } | |
| 55 | + return candidates.contains { FileManager.default.fileExists(atPath: $0.path) } | |
| 56 | + #else | |
| 57 | + return false | |
| 58 | + #endif | |
| 59 | + }() | |
| 60 | + | |
| 61 | + /// Runs `replicates` pairs-bootstrap OLS fits and returns the | |
| 62 | + /// coefficient draws, one row per replicate (constant last when | |
| 63 | + /// `includeConstant`). Replicates whose resampled design is singular | |
| 64 | + /// come back as NaN rows for the caller to drop. | |
| 65 | + public static func pairsBootstrapOLS( | |
| 66 | + y: [Double], | |
| 67 | + predictors: [(name: String, values: [Double])], | |
| 68 | + includeConstant: Bool = true, | |
| 69 | + replicates: Int, | |
| 70 | + seed: UInt64, | |
| 71 | + memoryBudgetBytes: Int = 512 << 20 | |
| 72 | + ) -> [[Double]] { | |
| 73 | + let n = y.count | |
| 74 | + let p = predictors.count | |
| 75 | + let k = p + (includeConstant ? 1 : 0) | |
| 76 | + precondition(n > k, "insufficient observations") | |
| 77 | + | |
| 78 | + // Conditioning: the GPU stage forms X'X in float32, which squares | |
| 79 | + // the condition number — raw data with an intercept loses 2–3 | |
| 80 | + // digits. Standardizing each predictor by its full-sample mean and | |
| 81 | + // scale is an exact reparametrization (the shift/scale constants | |
| 82 | + // are fixed across replicates), so the original coefficients are | |
| 83 | + // recovered exactly after the float64 solve: | |
| 84 | + // β_j = β̃_j / s_j, b₀ = α + ȳ − Σ c_j β_j. | |
| 85 | + var shifts = [Double](repeating: 0, count: p) | |
| 86 | + var scales = [Double](repeating: 1, count: p) | |
| 87 | + var yShift = 0.0 | |
| 88 | + if includeConstant { | |
| 89 | + yShift = y.reduce(0, +) / Double(n) | |
| 90 | + for (j, column) in predictors.enumerated() { | |
| 91 | + let mean = column.values.reduce(0, +) / Double(n) | |
| 92 | + let variance = column.values.reduce(0) { | |
| 93 | + $0 + ($1 - mean) * ($1 - mean) | |
| 94 | + } / Double(n) | |
| 95 | + shifts[j] = mean | |
| 96 | + scales[j] = variance.squareRoot() > 1e-30 ? variance.squareRoot() : 1 | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + // Standardized design matrix on the GPU, float32, row-major (n, k). | |
| 101 | + var design = [Float]() | |
| 102 | + design.reserveCapacity(n * k) | |
| 103 | + for row in 0..<n { | |
| 104 | + for (j, column) in predictors.enumerated() { | |
| 105 | + design.append(Float((column.values[row] - shifts[j]) / scales[j])) | |
| 106 | + } | |
| 107 | + if includeConstant { design.append(1) } | |
| 108 | + } | |
| 109 | + let x = MLXArray(design, [n, k]) | |
| 110 | + let yGPU = MLXArray(y.map { Float($0 - yShift) }, [n, 1]) | |
| 111 | + | |
| 112 | + // §5 chunking: keep the gathered design under the memory budget. | |
| 113 | + let bytesPerReplicate = n * (k + 1) * 4 * 2 // gathered X & y + transposes | |
| 114 | + let chunkSize = max(1, min(replicates, memoryBudgetBytes / bytesPerReplicate)) | |
| 115 | + | |
| 116 | + var draws = [[Double]](repeating: [], count: replicates) | |
| 117 | + var start = 0 | |
| 118 | + while start < replicates { | |
| 119 | + let count = min(chunkSize, replicates - start) | |
| 120 | + let (xtx, xty) = crossProducts( | |
| 121 | + x: x, y: yGPU, n: n, k: k, | |
| 122 | + firstReplicate: start, replicateCount: count, seed: seed | |
| 123 | + ) | |
| 124 | + solveChunk( | |
| 125 | + xtx: xtx, xty: xty, k: k, | |
| 126 | + into: &draws, offset: start, count: count | |
| 127 | + ) | |
| 128 | + start += count | |
| 129 | + } | |
| 130 | + | |
| 131 | + // Undo the standardization. | |
| 132 | + if includeConstant { | |
| 133 | + for r in 0..<replicates where !draws[r].isEmpty { | |
| 134 | + var recovered = draws[r] | |
| 135 | + var interceptShift = yShift | |
| 136 | + for j in 0..<p { | |
| 137 | + recovered[j] = draws[r][j] / scales[j] | |
| 138 | + interceptShift -= shifts[j] * recovered[j] | |
| 139 | + } | |
| 140 | + recovered[p] = draws[r][p] + interceptShift | |
| 141 | + draws[r] = recovered | |
| 142 | + } | |
| 143 | + } | |
| 144 | + return draws | |
| 145 | + } | |
| 146 | + | |
| 147 | + /// One replicate's resample indices computed on the GPU — exposed so | |
| 148 | + /// the cross-backend parity test can compare against the CPU | |
| 149 | + /// reference (`ZQResampling.pairsBootstrapIndices`) bit-for-bit. | |
| 150 | + public static func gpuIndices( | |
| 151 | + replicate: Int, sampleSize: Int, seed: UInt64 | |
| 152 | + ) -> [Int] { | |
| 153 | + let base = UInt64(replicate) * UInt64(sampleSize) | |
| 154 | + let positions = MLXArray(0..<Int32(sampleSize)).asType(.uint64) | |
| 155 | + + MLXArray(base, dtype: .uint64) | |
| 156 | + let indices = philoxBoundedIndices( | |
| 157 | + positions: positions, bound: sampleSize, seed: seed | |
| 158 | + ) | |
| 159 | + eval(indices) | |
| 160 | + return indices.asArray(Int32.self).map(Int.init) | |
| 161 | + } | |
| 162 | + | |
| 163 | + /// Diagnostic hook: one replicate's raw GPU cross-products with the | |
| 164 | + /// same standardization as the production path. | |
| 165 | + public static func debugCrossProducts( | |
| 166 | + y: [Double], predictors: [(name: String, values: [Double])], | |
| 167 | + replicate: Int, seed: UInt64 | |
| 168 | + ) -> (xtx: [Float], xty: [Float]) { | |
| 169 | + let n = y.count | |
| 170 | + let p = predictors.count | |
| 171 | + let k = p + 1 | |
| 172 | + let meanY = y.reduce(0, +) / Double(n) | |
| 173 | + var design = [Float]() | |
| 174 | + for row in 0..<n { | |
| 175 | + for column in predictors { | |
| 176 | + let mean = column.values.reduce(0, +) / Double(n) | |
| 177 | + let variance = column.values.reduce(0) { | |
| 178 | + $0 + ($1 - mean) * ($1 - mean) | |
| 179 | + } / Double(n) | |
| 180 | + design.append(Float((column.values[row] - mean) / variance.squareRoot())) | |
| 181 | + } | |
| 182 | + design.append(1) | |
| 183 | + } | |
| 184 | + let x = MLXArray(design, [n, k]) | |
| 185 | + let yGPU = MLXArray(y.map { Float($0 - meanY) }, [n, 1]) | |
| 186 | + return crossProducts( | |
| 187 | + x: x, y: yGPU, n: n, k: k, | |
| 188 | + firstReplicate: replicate, replicateCount: 1, seed: seed | |
| 189 | + ) | |
| 190 | + } | |
| 191 | + | |
| 192 | + /// Diagnostic hook: cross-products for replicate 0 computed inside a | |
| 193 | + /// batch of `batchSize`, to expose batch-dependent kernel numerics. | |
| 194 | + public static func debugBatchedCrossProducts( | |
| 195 | + y: [Double], predictors: [(name: String, values: [Double])], | |
| 196 | + batchSize: Int, seed: UInt64 | |
| 197 | + ) -> (xtx: [Float], xty: [Float]) { | |
| 198 | + let n = y.count | |
| 199 | + let p = predictors.count | |
| 200 | + let k = p + 1 | |
| 201 | + let meanY = y.reduce(0, +) / Double(n) | |
| 202 | + var design = [Float]() | |
| 203 | + for row in 0..<n { | |
| 204 | + for column in predictors { | |
| 205 | + let mean = column.values.reduce(0, +) / Double(n) | |
| 206 | + let variance = column.values.reduce(0) { | |
| 207 | + $0 + ($1 - mean) * ($1 - mean) | |
| 208 | + } / Double(n) | |
| 209 | + design.append(Float((column.values[row] - mean) / variance.squareRoot())) | |
| 210 | + } | |
| 211 | + design.append(1) | |
| 212 | + } | |
| 213 | + let x = MLXArray(design, [n, k]) | |
| 214 | + let yGPU = MLXArray(y.map { Float($0 - meanY) }, [n, 1]) | |
| 215 | + let (xtx, xty) = crossProducts( | |
| 216 | + x: x, y: yGPU, n: n, k: k, | |
| 217 | + firstReplicate: 0, replicateCount: batchSize, seed: seed | |
| 218 | + ) | |
| 219 | + return (Array(xtx.prefix(k * k)), Array(xty.prefix(k))) | |
| 220 | + } | |
| 221 | + | |
| 222 | + // MARK: - GPU stage | |
| 223 | + | |
| 224 | + /// Gathers the resampled design for a chunk and returns float32 | |
| 225 | + /// cross-products, pulled back to the CPU: X'X (count·k·k) and X'y | |
| 226 | + /// (count·k). | |
| 227 | + private static func crossProducts( | |
| 228 | + x: MLXArray, y: MLXArray, n: Int, k: Int, | |
| 229 | + firstReplicate: Int, replicateCount: Int, seed: UInt64 | |
| 230 | + ) -> (xtx: [Float], xty: [Float]) { | |
| 231 | + // Draw positions: replicate r uses counters r·n ..< (r+1)·n, | |
| 232 | + // matching ZQResampling.pairsBootstrapIndices. | |
| 233 | + let base = UInt64(firstReplicate) * UInt64(n) | |
| 234 | + let total = replicateCount * n | |
| 235 | + let positions = MLXArray(0..<Int32(total)).asType(.uint64) | |
| 236 | + + MLXArray(base, dtype: .uint64) | |
| 237 | + | |
| 238 | + let indices = philoxBoundedIndices(positions: positions, bound: n, seed: seed) | |
| 239 | + .reshaped([replicateCount, n]) | |
| 240 | + | |
| 241 | + // Gather rows: (count, n, k) and (count, n, 1). | |
| 242 | + let xg = x.take(indices.flattened(), axis: 0) | |
| 243 | + .reshaped([replicateCount, n, k]) | |
| 244 | + let yg = y.take(indices.flattened(), axis: 0) | |
| 245 | + .reshaped([replicateCount, n, 1]) | |
| 246 | + | |
| 247 | + // Batched cross-products. X'X is computed one column at a time | |
| 248 | + // through (count,k,n)@(count,n,1) products: MLX's batched GEMM | |
| 249 | + // mis-accumulates small k×k outputs at batch ≥ 2 (~6e-4 relative, | |
| 250 | + // verified against float64 sums, independent of strides/fusion), | |
| 251 | + // while the single-column path is float32-exact. k is single-digit | |
| 252 | + // so the extra dispatches are negligible next to the gather. | |
| 253 | + let xt = xg.transposed(0, 2, 1) // (count, k, n) | |
| 254 | + let xColumns = split(xg, parts: k, axis: 2) // k × (count, n, 1) | |
| 255 | + let xtxColumns = xColumns.map { matmul(xt, $0) } | |
| 256 | + let xtx = concatenated(xtxColumns, axis: 2) // (count, k, k) | |
| 257 | + let xty = matmul(xt, yg) // (count, k, 1) | |
| 258 | + eval(xtx, xty) | |
| 259 | + | |
| 260 | + return (xtx.asArray(Float.self), xty.asArray(Float.self)) | |
| 261 | + } | |
| 262 | + | |
| 263 | + /// Philox4x32-10 as vectorized MLX ops. Given absolute draw positions, | |
| 264 | + /// returns bounded int32 indices in 0..<bound — bit-identical to | |
| 265 | + /// `Philox4x32.integer(at:bound:)` on the CPU. | |
| 266 | + static func philoxBoundedIndices( | |
| 267 | + positions: MLXArray, bound: Int, seed: UInt64 | |
| 268 | + ) -> MLXArray { | |
| 269 | + let mask32 = MLXArray(UInt64(0xFFFF_FFFF), dtype: .uint64) | |
| 270 | + let block = positions >> 2 | |
| 271 | + let lane = (positions & MLXArray(UInt64(3), dtype: .uint64)).asType(.uint32) | |
| 272 | + | |
| 273 | + var c0 = (block & mask32) | |
| 274 | + var c1 = (block >> 32) | |
| 275 | + var c2 = MLXArray.zeros(positions.shape, dtype: .uint64) | |
| 276 | + var c3 = MLXArray.zeros(positions.shape, dtype: .uint64) | |
| 277 | + | |
| 278 | + // Key schedule precomputed on the CPU — identical to the | |
| 279 | + // reference: key_r = (seedLo + r·W0, seedHi + r·W1) mod 2³². | |
| 280 | + let seedLo = UInt32(truncatingIfNeeded: seed) | |
| 281 | + let seedHi = UInt32(truncatingIfNeeded: seed >> 32) | |
| 282 | + let m0 = MLXArray(UInt64(0xD251_1F53), dtype: .uint64) | |
| 283 | + let m1 = MLXArray(UInt64(0xCD9E_8D57), dtype: .uint64) | |
| 284 | + | |
| 285 | + for round in 0..<10 { | |
| 286 | + let k0 = seedLo &+ UInt32(round) &* 0x9E37_79B9 | |
| 287 | + let k1 = seedHi &+ UInt32(round) &* 0xBB67_AE85 | |
| 288 | + let product0 = m0 * c0 // c0 < 2³² so exact | |
| 289 | + let product1 = m1 * c2 | |
| 290 | + let high0 = product0 >> 32 | |
| 291 | + let low0 = product0 & mask32 | |
| 292 | + let high1 = product1 >> 32 | |
| 293 | + let low1 = product1 & mask32 | |
| 294 | + let newC0 = (high1 ^ c1 ^ MLXArray(UInt64(k0), dtype: .uint64)) & mask32 | |
| 295 | + let newC2 = (high0 ^ c3 ^ MLXArray(UInt64(k1), dtype: .uint64)) & mask32 | |
| 296 | + c0 = newC0 | |
| 297 | + c1 = low1 | |
| 298 | + c2 = newC2 | |
| 299 | + c3 = low0 | |
| 300 | + } | |
| 301 | + | |
| 302 | + // Lane select, then bounded multiply-shift like the reference. | |
| 303 | + let word = which( | |
| 304 | + lane .== MLXArray(UInt32(0), dtype: .uint32), c0, | |
| 305 | + which( | |
| 306 | + lane .== MLXArray(UInt32(1), dtype: .uint32), c1, | |
| 307 | + which(lane .== MLXArray(UInt32(2), dtype: .uint32), c2, c3) | |
| 308 | + ) | |
| 309 | + ) | |
| 310 | + let bounded = (word * MLXArray(UInt64(bound), dtype: .uint64)) >> 32 | |
| 311 | + return bounded.asType(.int32) | |
| 312 | + } | |
| 313 | + | |
| 314 | + // MARK: - CPU stage | |
| 315 | + | |
| 316 | + /// Solves the chunk's normal equations in float64 via Cholesky. | |
| 317 | + /// Singular systems yield NaN rows. | |
| 318 | + private static func solveChunk( | |
| 319 | + xtx: [Float], xty: [Float], k: Int, | |
| 320 | + into draws: inout [[Double]], offset: Int, count: Int | |
| 321 | + ) { | |
| 322 | + for r in 0..<count { | |
| 323 | + var a = [Double](repeating: 0, count: k * k) | |
| 324 | + var b = [Double](repeating: 0, count: k) | |
| 325 | + // MLX buffers are row-major; LAPACK wants column-major. X'X is | |
| 326 | + // symmetric so the transpose is free; X'y is a vector. | |
| 327 | + for i in 0..<k { | |
| 328 | + b[i] = Double(xty[r * k + i]) | |
| 329 | + for j in 0..<k { | |
| 330 | + a[j * k + i] = Double(xtx[r * k * k + i * k + j]) | |
| 331 | + } | |
| 332 | + } | |
| 333 | + if let solution = try? CholeskySolver.solve(a, k: k, rhs: b) { | |
| 334 | + draws[offset + r] = solution | |
| 335 | + } else { | |
| 336 | + draws[offset + r] = [Double](repeating: .nan, count: k) | |
| 337 | + } | |
| 338 | + } | |
| 339 | + } | |
| 340 | +} | |
added
MetrikaKit/Tests/MetrikaKitTests/GPUBootstrapTests.swift
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +// | |
| 2 | +// GPUBootstrapTests.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import Foundation | |
| 11 | +import Testing | |
| 12 | +import ZQGPU | |
| 13 | +import ZQParser | |
| 14 | +import ZQPlanner | |
| 15 | +import ZQStats | |
| 16 | + | |
| 17 | +/// GPU bootstrap cross-checks (CLAUDE.md §9): same seed ⇒ identical | |
| 18 | +/// resample indices across backends; estimates within the documented | |
| 19 | +/// float32 tolerance. | |
| 20 | +@Suite("GPU bootstrap", .enabled(if: ZQGPUBootstrap.isAvailable)) | |
| 21 | +struct GPUBootstrapTests { | |
| 22 | + | |
| 23 | + @Test("MLX Philox indices are bit-identical to the CPU reference") | |
| 24 | + func philoxParity() { | |
| 25 | + let seed: UInt64 = 42 | |
| 26 | + let n = 137 | |
| 27 | + let generator = Philox4x32(seed: seed) | |
| 28 | + | |
| 29 | + for replicate in [0, 1, 7, 1000] { | |
| 30 | + let cpu = ZQResampling.pairsBootstrapIndices( | |
| 31 | + replicate: replicate, sampleSize: n, generator: generator | |
| 32 | + ) | |
| 33 | + let gpu = ZQGPUBootstrap.gpuIndices(replicate: replicate, sampleSize: n, seed: seed) | |
| 34 | + #expect(cpu == gpu, "replicate \(replicate)") | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Test("GPU coefficient draws match CPU within float32 tolerance") | |
| 39 | + func drawParity() throws { | |
| 40 | + // Small synthetic problem, well conditioned. | |
| 41 | + let n = 200 | |
| 42 | + let generator = Philox4x32(seed: 7) | |
| 43 | + let x = (0..<n).map { i in 5 + 10 * generator.uniform(at: UInt64(i)) } | |
| 44 | + let y = (0..<n).map { i in | |
| 45 | + 2 + 0.5 * x[i] + (generator.uniform(at: UInt64(n + i)) - 0.5) | |
| 46 | + } | |
| 47 | + let reps = 50 | |
| 48 | + | |
| 49 | + let gpuDraws = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 50 | + y: y, predictors: [("x", x)], replicates: reps, seed: 42 | |
| 51 | + ) | |
| 52 | + | |
| 53 | + let bootstrapGenerator = Philox4x32(seed: 42) | |
| 54 | + for replicate in 0..<reps { | |
| 55 | + let indices = ZQResampling.pairsBootstrapIndices( | |
| 56 | + replicate: replicate, sampleSize: n, generator: bootstrapGenerator | |
| 57 | + ) | |
| 58 | + let fit = try ZQOLS.fit( | |
| 59 | + y: indices.map { y[$0] }, | |
| 60 | + predictors: [("x", indices.map { x[$0] })] | |
| 61 | + ) | |
| 62 | + for (j, coefficient) in fit.coefficients.enumerated() { | |
| 63 | + expectClose( | |
| 64 | + gpuDraws[replicate][j], coefficient.estimate, | |
| 65 | + rtol: 1e-4, "replicate \(replicate) b[\(coefficient.name)]" | |
| 66 | + ) | |
| 67 | + } | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + @Test("GPU bootstrap is deterministic for a fixed seed") | |
| 72 | + func determinism() { | |
| 73 | + let n = 100 | |
| 74 | + let generator = Philox4x32(seed: 3) | |
| 75 | + let x = (0..<n).map { i in generator.uniform(at: UInt64(i)) } | |
| 76 | + let y = (0..<n).map { i in x[i] + generator.uniform(at: UInt64(n + i)) } | |
| 77 | + | |
| 78 | + let first = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 79 | + y: y, predictors: [("x", x)], replicates: 20, seed: 99 | |
| 80 | + ) | |
| 81 | + let second = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 82 | + y: y, predictors: [("x", x)], replicates: 20, seed: 99 | |
| 83 | + ) | |
| 84 | + #expect(first == second) | |
| 85 | + } | |
| 86 | + | |
| 87 | + @Test("chunking does not change results") | |
| 88 | + func chunkingInvariance() { | |
| 89 | + let n = 80 | |
| 90 | + let generator = Philox4x32(seed: 5) | |
| 91 | + let x = (0..<n).map { i in generator.uniform(at: UInt64(i)) } | |
| 92 | + let y = (0..<n).map { i in 2 * x[i] + generator.uniform(at: UInt64(n + i)) } | |
| 93 | + | |
| 94 | + let oneChunk = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 95 | + y: y, predictors: [("x", x)], replicates: 30, seed: 11 | |
| 96 | + ) | |
| 97 | + // Budget so small every chunk holds a single replicate. | |
| 98 | + let manyChunks = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 99 | + y: y, predictors: [("x", x)], replicates: 30, seed: 11, | |
| 100 | + memoryBudgetBytes: 1 | |
| 101 | + ) | |
| 102 | + #expect(oneChunk == manyChunks) | |
| 103 | + } | |
| 104 | + | |
| 105 | + @Test("planner sends large-reps bootstrap to the GPU") | |
| 106 | + func plannerDispatch() throws { | |
| 107 | + let planner = ZQPlanner(gpuAvailable: true) | |
| 108 | + let parser = ZQCommandParser() | |
| 109 | + | |
| 110 | + let large = try #require(try parser.parse("bootstrap, reps(10000): reg y x")) | |
| 111 | + #expect(planner.plan(large, rowCount: 1000).backend == .gpu) | |
| 112 | + | |
| 113 | + let small = try #require(try parser.parse("bootstrap, reps(100): reg y x")) | |
| 114 | + #expect(planner.plan(small, rowCount: 1000).backend == .cpu) | |
| 115 | + | |
| 116 | + let noGPU = ZQPlanner(gpuAvailable: false) | |
| 117 | + #expect(noGPU.plan(large, rowCount: 1000).backend == .cpu) | |
| 118 | + } | |
| 119 | +} | |
added
MetrikaKit/Tests/MetrikaKitTests/GPUDiagnosticTests.swift
+73 −0
@@ -0,0 +1,73 @@ | ||
| 1 | +// | |
| 2 | +// GPUDiagnosticTests.swift | |
| 3 | +// Metrika | |
| 4 | +// | |
| 5 | +// Author: Simon-Pierre Boucher | |
| 6 | +// Contact: contact@spboucher.ai | |
| 7 | +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved. | |
| 8 | +// | |
| 9 | + | |
| 10 | +import Foundation | |
| 11 | +import Testing | |
| 12 | +import ZQGPU | |
| 13 | + | |
| 14 | +/// Numerical regression tests for the GPU cross-product stage. These | |
| 15 | +/// pin down an MLX batched-GEMM issue: small k×k outputs mis-accumulate | |
| 16 | +/// (~6e-4 relative) for batch sizes ≥ 2, which the column-wise X'X | |
| 17 | +/// computation in `crossProducts` works around. If these start failing, | |
| 18 | +/// the workaround regressed or the kernel changed. | |
| 19 | +@Suite("GPU numerics regression", .enabled(if: ZQGPUBootstrap.isAvailable)) | |
| 20 | +struct GPUDiagnosticTests { | |
| 21 | + let n = 200 | |
| 22 | + let x: [Double] | |
| 23 | + let y: [Double] | |
| 24 | + | |
| 25 | + init() { | |
| 26 | + let generator = Philox4x32(seed: 7) | |
| 27 | + let count = 200 | |
| 28 | + let xs = (0..<count).map { i in 5 + 10 * generator.uniform(at: UInt64(i)) } | |
| 29 | + x = xs | |
| 30 | + y = (0..<count).map { i in | |
| 31 | + 2 + 0.5 * xs[i] + (generator.uniform(at: UInt64(count + i)) - 0.5) | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test("batched cross-products match float64 sums of identical data") | |
| 36 | + func crossProductPrecision() { | |
| 37 | + // Exact float64 sums of the float32-quantized data the GPU sees. | |
| 38 | + let indices = ZQResampling.pairsBootstrapIndices( | |
| 39 | + replicate: 0, sampleSize: n, generator: Philox4x32(seed: 42) | |
| 40 | + ) | |
| 41 | + let meanX = x.reduce(0, +) / Double(n) | |
| 42 | + let varX = x.reduce(0) { $0 + ($1 - meanX) * ($1 - meanX) } / Double(n) | |
| 43 | + let meanY = y.reduce(0, +) / Double(n) | |
| 44 | + let xs = indices.map { Float((x[$0] - meanX) / varX.squareRoot()) } | |
| 45 | + let ys = indices.map { Float(y[$0] - meanY) } | |
| 46 | + var sxx = 0.0, sxy = 0.0 | |
| 47 | + for i in 0..<n { | |
| 48 | + sxx += Double(xs[i]) * Double(xs[i]) | |
| 49 | + sxy += Double(xs[i]) * Double(ys[i]) | |
| 50 | + } | |
| 51 | + | |
| 52 | + for batch in [1, 2, 16] { | |
| 53 | + let sums = ZQGPUBootstrap.debugBatchedCrossProducts( | |
| 54 | + y: y, predictors: [("x", x)], batchSize: batch, seed: 42 | |
| 55 | + ) | |
| 56 | + expectClose(Double(sums.xtx[0]), sxx, rtol: 1e-5, "sxx batch=\(batch)") | |
| 57 | + expectClose(Double(sums.xty[0]), sxy, rtol: 1e-5, "sxy batch=\(batch)") | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test("replicate draws are independent of batch size") | |
| 62 | + func batchSizeInvariance() { | |
| 63 | + let reference = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 64 | + y: y, predictors: [("x", x)], replicates: 1, seed: 42 | |
| 65 | + )[0] | |
| 66 | + for batch in [2, 10, 50] { | |
| 67 | + let draws = ZQGPUBootstrap.pairsBootstrapOLS( | |
| 68 | + y: y, predictors: [("x", x)], replicates: batch, seed: 42 | |
| 69 | + ) | |
| 70 | + #expect(draws[0] == reference, "batch=\(batch)") | |
| 71 | + } | |
| 72 | + } | |
| 73 | +} | |
| 74 | ||