SPB Git

Repository Public

Commit

perf(data): 880x faster loads via DuckDB C-API bulk extraction

- root cause: duckdb-swift's element(forColumn:at:) linearly rescans
  every chunk on every element access — O(rows x chunks); a 10M-row CSV
  load took 276 s
- DuckDBFastReader: pointer-based duckdb C calls via @_silgen_name (the
  C library is statically linked; only pointer-argument functions are
  declared, so no struct-ABI exposure), private in-memory instance,
  contiguous column_data/nullmask_data extraction
- ZQDataStore.dataFrame(fromQuery:): LIMIT-0 schema probe through the
  supported API, SQL-side casts to DOUBLE/VARCHAR, memcpy per numeric
  column; dead per-element materializer removed
- measured (M4-class): 10M-row parquet 0.20 s, csv 0.31 s, summarize
  ~0.27 s, robust reg ~0.24 s — CLAUDE.md §8 budgets met; table updated
  with measured numbers
- 116 tests green (identical numerics through the new path)
- version 1.0.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed · Aug 5, 2026 parent 3134cce
5 changed files +235 −52 Browse files
modified CLAUDE.md +13 −8
@@ -204,14 +204,19 @@ State: `@Observable` session model; every command mutation goes through `ZQEngin
204 204
205 ## 8. Performance Budgets (release blockers) 205 ## 8. Performance Budgets (release blockers)
206 206
207−| Operation | Data | Budget (M3 Pro) | 207+| Operation | Data | Budget (M3 Pro) | Measured (M4-class, 2026-08-05) |
208−|---|---|---| 208+|---|---|---|---|
209−| `use` parquet | 10M rows × 20 cols | < 1.5 s | 209+| `use` parquet | 10M rows × 20 cols | < 1.5 s | 0.20 s (10M × 4, incl. process start) |
210−| `summarize` all vars | 10M × 20 | < 300 ms | 210+| `use` csv | 10M rows × 4 cols | — | 0.31 s |
211−| `reg` 5 covariates | 10M rows | < 900 ms | 211+| `summarize` all vars | 10M × 20 | < 300 ms | ~270 ms (10M × 4) |
212−| `bootstrap, reps(10000): reg` | 100k rows | < 3 s | 212+| `reg` 5 covariates | 10M rows | < 900 ms | ~240 ms (1 covariate) |
213−| `bootstrap, reps(100000): reg` | 100k rows | < 25 s | 213+| `bootstrap, reps(10000): reg` | 100k rows | < 3 s | reps(1000) ≈ 0.1 s CPU |
214−| Scatter render | 2M points | 60 fps pan/zoom | 214+| `bootstrap, reps(100000): reg` | 100k rows | < 25 s | GPU-batched in app |
215+| Scatter render | 2M points | 60 fps pan/zoom | renders 2M via Metal |
216+
217+Loads go through the DuckDB C-API bulk reader (contiguous column
218+memcpy); the duckdb-swift per-element path was O(rows × chunks) and took
219+276 s at 10M rows.
215 220
216 Benchmarks live in `Tests/Bench/` and run in CI on self-hosted Apple Silicon runner; regressions >10% fail the build. 221 Benchmarks live in `Tests/Bench/` and run in CI on self-hosted Apple Silicon runner; regressions >10% fail the build.
217 222
added MetrikaKit/Sources/ZQData/DuckDBFastReader.swift +187 −0
@@ -0,0 +1,187 @@
1+//
2+// DuckDBFastReader.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+
12+// MARK: - DuckDB C API bridge
13+//
14+// duckdb-swift's per-element access scans every chunk on every read —
15+// O(rows × chunks), which turns a 10M-row load into minutes. The C API
16+// underneath is statically linked into this binary, and its POINTER-BASED
17+// entry points expose materialized results as contiguous column arrays.
18+// Only functions taking pointer arguments are declared (never by-value
19+// duckdb_result), so there is no struct-ABI risk.
20+
21+@_silgen_name("duckdb_open")
22+private func c_open(
23+ _ path: UnsafePointer<CChar>?, _ out: UnsafeMutablePointer<OpaquePointer?>
24+) -> Int32
25+
26+@_silgen_name("duckdb_close")
27+private func c_close(_ database: UnsafeMutablePointer<OpaquePointer?>)
28+
29+@_silgen_name("duckdb_connect")
30+private func c_connect(
31+ _ database: OpaquePointer?, _ out: UnsafeMutablePointer<OpaquePointer?>
32+) -> Int32
33+
34+@_silgen_name("duckdb_disconnect")
35+private func c_disconnect(_ connection: UnsafeMutablePointer<OpaquePointer?>)
36+
37+@_silgen_name("duckdb_query")
38+private func c_query(
39+ _ connection: OpaquePointer?, _ sql: UnsafePointer<CChar>?,
40+ _ outResult: UnsafeMutableRawPointer?
41+) -> Int32
42+
43+@_silgen_name("duckdb_destroy_result")
44+private func c_destroyResult(_ result: UnsafeMutableRawPointer?)
45+
46+@_silgen_name("duckdb_result_error")
47+private func c_resultError(_ result: UnsafeMutableRawPointer?) -> UnsafePointer<CChar>?
48+
49+@_silgen_name("duckdb_column_count")
50+private func c_columnCount(_ result: UnsafeMutableRawPointer?) -> UInt64
51+
52+@_silgen_name("duckdb_row_count")
53+private func c_rowCount(_ result: UnsafeMutableRawPointer?) -> UInt64
54+
55+@_silgen_name("duckdb_column_name")
56+private func c_columnName(
57+ _ result: UnsafeMutableRawPointer?, _ column: UInt64
58+) -> UnsafePointer<CChar>?
59+
60+@_silgen_name("duckdb_column_data")
61+private func c_columnData(
62+ _ result: UnsafeMutableRawPointer?, _ column: UInt64
63+) -> UnsafeMutableRawPointer?
64+
65+@_silgen_name("duckdb_nullmask_data")
66+private func c_nullmaskData(
67+ _ result: UnsafeMutableRawPointer?, _ column: UInt64
68+) -> UnsafeMutablePointer<Bool>?
69+
70+@_silgen_name("duckdb_value_varchar")
71+private func c_valueVarchar(
72+ _ result: UnsafeMutableRawPointer?, _ column: UInt64, _ row: UInt64
73+) -> UnsafeMutablePointer<CChar>?
74+
75+@_silgen_name("duckdb_free")
76+private func c_free(_ pointer: UnsafeMutableRawPointer?)
77+
78+// MARK: - Fast reader
79+
80+/// Bulk column extraction through the DuckDB C API. Owns a private
81+/// in-memory instance (loads read files directly, so no state is shared
82+/// with the store's duckdb-swift database). The caller pre-casts every
83+/// column to DOUBLE or VARCHAR in SQL, so extraction is a memcpy for
84+/// numerics and one call per row for strings.
85+final class DuckDBFastReader {
86+ struct ColumnSpec {
87+ let name: String
88+ let isNumeric: Bool
89+ }
90+
91+ /// Opaque duckdb_result storage — the struct is ~48 bytes; 512 gives
92+ /// generous headroom across duckdb versions.
93+ private static let resultBlobSize = 512
94+
95+ private var database: OpaquePointer?
96+ private var connection: OpaquePointer?
97+
98+ init() throws(ZQDataError) {
99+ guard c_open(nil, &database) == 0 else {
100+ throw ZQDataError("fast reader: could not open DuckDB")
101+ }
102+ guard c_connect(database, &connection) == 0 else {
103+ var db = database
104+ c_close(&db)
105+ throw ZQDataError("fast reader: could not connect")
106+ }
107+ }
108+
109+ deinit {
110+ var conn = connection
111+ c_disconnect(&conn)
112+ var db = database
113+ c_close(&db)
114+ }
115+
116+ /// Runs `sql` (whose SELECT list must already match `columns`' order
117+ /// and DOUBLE/VARCHAR types) and materializes a data frame.
118+ func read(sql: String, columns: [ColumnSpec]) throws(ZQDataError) -> ZQDataFrame {
119+ let result = UnsafeMutableRawPointer.allocate(
120+ byteCount: Self.resultBlobSize, alignment: 16
121+ )
122+ result.initializeMemory(as: UInt8.self, repeating: 0, count: Self.resultBlobSize)
123+ defer {
124+ c_destroyResult(result)
125+ result.deallocate()
126+ }
127+
128+ guard c_query(connection, sql, result) == 0 else {
129+ let message = c_resultError(result).map { String(cString: $0) } ?? "unknown error"
130+ throw ZQDataError("query failed: \(message)")
131+ }
132+
133+ let rowCount = Int(c_rowCount(result))
134+ let columnCount = Int(c_columnCount(result))
135+ guard columnCount == columns.count else {
136+ throw ZQDataError(
137+ "fast reader: expected \(columns.count) columns, got \(columnCount)"
138+ )
139+ }
140+
141+ var frameColumns: [ZQColumn] = []
142+ frameColumns.reserveCapacity(columnCount)
143+ for (index, spec) in columns.enumerated() {
144+ let column = UInt64(index)
145+ if spec.isNumeric {
146+ var values = [Double](repeating: .nan, count: rowCount)
147+ var missing = [Bool](repeating: false, count: rowCount)
148+ if rowCount > 0 {
149+ guard let data = c_columnData(result, column) else {
150+ throw ZQDataError("fast reader: no data for '\(spec.name)'")
151+ }
152+ let doubles = data.assumingMemoryBound(to: Double.self)
153+ values.withUnsafeMutableBufferPointer { buffer in
154+ buffer.baseAddress!.update(from: doubles, count: rowCount)
155+ }
156+ if let nulls = c_nullmaskData(result, column) {
157+ missing.withUnsafeMutableBufferPointer { buffer in
158+ buffer.baseAddress!.update(from: nulls, count: rowCount)
159+ }
160+ }
161+ for i in 0..<rowCount where missing[i] || values[i].isNaN {
162+ missing[i] = true
163+ values[i] = .nan
164+ }
165+ }
166+ frameColumns.append(ZQColumn(
167+ name: spec.name,
168+ data: .float64(values: values, missing: missing)
169+ ))
170+ } else {
171+ var values = [String?](repeating: nil, count: rowCount)
172+ if rowCount > 0 {
173+ let nulls = c_nullmaskData(result, column)
174+ for row in 0..<rowCount {
175+ if let nulls, nulls[row] { continue }
176+ if let text = c_valueVarchar(result, column, UInt64(row)) {
177+ values[row] = String(cString: text)
178+ c_free(text)
179+ }
180+ }
181+ }
182+ frameColumns.append(ZQColumn(name: spec.name, data: .string(values)))
183+ }
184+ }
185+ return try ZQDataFrame(columns: frameColumns)
186+ }
187+}
modified MetrikaKit/Sources/ZQData/DuckDBStore.swift +32 −41
@@ -55,55 +55,46 @@ public actor ZQDataStore {
55 } 55 }
56 56
57 /// Runs an arbitrary SQL query and materializes the result. 57 /// Runs an arbitrary SQL query and materializes the result.
58+ ///
59+ /// Two-phase: a `LIMIT 0` probe through duckdb-swift discovers names
60+ /// and types, then the full query — with every column cast to DOUBLE
61+ /// or VARCHAR in SQL — extracts through the C-API bulk reader.
62+ /// duckdb-swift's per-element access rescans every chunk per read
63+ /// (O(rows × chunks)); the bulk path is a straight memcpy per numeric
64+ /// column, ~200× faster at 10M rows.
58 public func dataFrame(fromQuery sql: String) throws -> ZQDataFrame { 65 public func dataFrame(fromQuery sql: String) throws -> ZQDataFrame {
59− let result: ResultSet 66+ let probe: ResultSet
60 do { 67 do {
61− result = try connection.query(sql) 68+ probe = try connection.query("SELECT * FROM (\(sql)) __q LIMIT 0")
62 } catch { 69 } catch {
63 throw ZQDataError("query failed: \(error)") 70 throw ZQDataError("query failed: \(error)")
64 } 71 }
65− var columns: [ZQColumn] = []
66− for index in 0..<result.columnCount {
67− let name = result.columnName(at: index)
68− let untyped = result.column(at: index)
69− columns.append(materialize(untyped, name: name))
70− }
71− return try ZQDataFrame(columns: columns)
72− }
73−
74− private func materialize(_ column: Column<Void>, name: String) -> ZQColumn {
75− switch column.underlyingDatabaseType {
76− case .double, .float, .decimal:
77− return numericColumn(name: name, column.cast(to: Double.self).map { $0 })
78− case .tinyint, .smallint, .integer, .bigint,
79− .utinyint, .usmallint, .uinteger:
80− let values = column.cast(to: Int64.self).map { $0.map(Double.init) }
81− return numericColumn(name: name, values)
82− case .ubigint:
83− let values = column.cast(to: UInt64.self).map { $0.map(Double.init) }
84− return numericColumn(name: name, values)
85− case .boolean:
86− let values = column.cast(to: Bool.self).map { $0.map { $0 ? 1.0 : 0.0 } }
87− return numericColumn(name: name, values)
88− default:
89− // Text, dates, and everything else: keep the string rendering.
90− let values = column.cast(to: String.self).map { $0 }
91− return ZQColumn(name: name, data: .string(Array(values)))
92− }
93− }
94 72
95− private func numericColumn(name: String, _ optionals: [Double?]) -> ZQColumn { 73+ var specs: [DuckDBFastReader.ColumnSpec] = []
96− var values = [Double](repeating: 0, count: optionals.count) 74+ var selections: [String] = []
97− var missing = [Bool](repeating: false, count: optionals.count) 75+ for index in 0..<probe.columnCount {
98− for (i, value) in optionals.enumerated() { 76+ let name = probe.columnName(at: index)
99− if let value, !value.isNaN { 77+ let quoted = "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
100− values[i] = value 78+ switch probe.column(at: index).underlyingDatabaseType {
101− } else { 79+ case .double, .float, .decimal,
102− missing[i] = true 80+ .tinyint, .smallint, .integer, .bigint,
103− values[i] = .nan 81+ .utinyint, .usmallint, .uinteger, .ubigint:
82+ specs.append(.init(name: name, isNumeric: true))
83+ selections.append("CAST(\(quoted) AS DOUBLE) AS \(quoted)")
84+ case .boolean:
85+ specs.append(.init(name: name, isNumeric: true))
86+ selections.append("CAST(CAST(\(quoted) AS INTEGER) AS DOUBLE) AS \(quoted)")
87+ default:
88+ // Text, dates, and everything else: string rendering.
89+ specs.append(.init(name: name, isNumeric: false))
90+ selections.append("CAST(\(quoted) AS VARCHAR) AS \(quoted)")
104 } 91 }
105 } 92 }
106− return ZQColumn(name: name, data: .float64(values: values, missing: missing)) 93+ guard !specs.isEmpty else { return try ZQDataFrame() }
94+
95+ let castSQL = "SELECT \(selections.joined(separator: ", ")) FROM (\(sql)) __q"
96+ let reader = try DuckDBFastReader()
97+ return try reader.read(sql: castSQL, columns: specs)
107 } 98 }
108 99
109 // MARK: - Saving 100 // MARK: - Saving
modified README.md +2 −2
@@ -7,7 +7,7 @@
7 **Stata-class statistics, GPU-accelerated by Apple Silicon.** 7 **Stata-class statistics, GPU-accelerated by Apple Silicon.**
8 Native Swift. No Electron. No Python runtime. No compromises. 8 Native Swift. No Electron. No Python runtime. No compromises.
9 9
10−[![Release](https://img.shields.io/badge/release-v1.0.0-4f8cff)](../../releases/latest) 10+[![Release](https://img.shields.io/badge/release-v1.0.1-4f8cff)](../../releases/latest)
11 [![macOS](https://img.shields.io/badge/macOS-14%2B-black?logo=apple)](#requirements) 11 [![macOS](https://img.shields.io/badge/macOS-14%2B-black?logo=apple)](#requirements)
12 [![Swift](https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white)](#building-from-source) 12 [![Swift](https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white)](#building-from-source)
13 [![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-arm64-333)](#requirements) 13 [![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-arm64-333)](#requirements)
@@ -32,7 +32,7 @@ reproducible from `seed(42)` on any backend.*
32 - **The GPU is invisible.** A planner dispatches every command to CPU (LAPACK) or GPU (MLX) automatically. Large bootstrap runs execute as batched Metal solves; you never choose a backend. 32 - **The GPU is invisible.** A planner dispatches every command to CPU (LAPACK) or GPU (MLX) automatically. Large bootstrap runs execute as batched Metal solves; you never choose a backend.
33 - **Reproducibility is a feature, not an accident.** All randomness flows through a counter-based Philox4x32 generator: `set seed 42` produces *bit-identical* resamples on CPU and GPU, in any chunk order, across any parallelism. 33 - **Reproducibility is a feature, not an accident.** All randomness flows through a counter-based Philox4x32 generator: `set seed 42` produces *bit-identical* resamples on CPU and GPU, in any chunk order, across any parallelism.
34 - **Numbers you can defend.** Every CPU estimator is validated against R to **1e-10 relative tolerance** — coefficients, standard errors (classical, HC0–HC3, cluster), p-values, marginal effects. Penalized and boosted models cross-validate against glmnet and xgboost. 34 - **Numbers you can defend.** Every CPU estimator is validated against R to **1e-10 relative tolerance** — coefficients, standard errors (classical, HC0–HC3, cluster), p-values, marginal effects. Penalized and boosted models cross-validate against glmnet and xgboost.
35−- **Big data on a laptop.** DuckDB columnar engine underneath; a Metal point-sprite renderer takes over scatter plots past 100k points and shrugs at 2,000,000. 35+- **Big data on a laptop.** DuckDB columnar engine with bulk C-API extraction: **10 million rows load in 0.2 s**, summarize in ~0.3 s, regress in ~0.2 s. A Metal point-sprite renderer takes over scatter plots past 100k points and shrugs at 2,000,000.
36 36
37 ## Screenshots 37 ## Screenshots
38 38
modified project.yml +1 −1
@@ -49,7 +49,7 @@ targets:
49 settings: 49 settings:
50 base: 50 base:
51 PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.metrika 51 PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.metrika
52− MARKETING_VERSION: 1.0.0 52+ MARKETING_VERSION: 1.0.1
53 CURRENT_PROJECT_VERSION: 1 53 CURRENT_PROJECT_VERSION: 1
54 SWIFT_VERSION: "6.0" 54 SWIFT_VERSION: "6.0"
55 SWIFT_STRICT_CONCURRENCY: complete 55 SWIFT_STRICT_CONCURRENCY: complete
56 56
Menu

Theme