v1.4.6 is an internal performance release. Every change is allocation-only: the public API, entity metadata, and rendered SQL are unchanged, and no migration is required.
Fewer allocations in query construction and rendering
Issue #128 established that constructing and rendering a query costs real allocations on every workload. For issue #166, a new deterministic allocation profiler attributed about three-quarters of that cost to rendering rather than construction, then pointed at three specific call sites in SQLiteEncoding.swift.
The builder’s token-append method took a variadic String... and filtered it, so every builder node allocated both a variadic array box and a filter copy, even though all 29 call sites pass exactly one already-rendered token:
// Before
private mutating func append(_ tokens: String...) {
_tokens.append(contentsOf: tokens.filter({ !$0.isEmpty }))
}
// After
private mutating func append(_ token: String) {
if !token.isEmpty {
_tokens.append(token)
}
}
build() always re-joined the token array, even for the single-token leaf and wrapper builders that make up most of the tree:
// Before
public func build() -> String {
_tokens.joined(separator: XLSeparator.tuple.rawValue)
}
// After
public func build() -> String {
if _tokens.count == 1 {
return _tokens[0]
}
return _tokens.joined(separator: XLSeparator.tuple.rawValue)
}
And scopedName(_:) ran every qualified reference, including the table.column shape that covers almost all of them, through values.map(name).joined("."):
// Before
public func scopedName(_ values: [String]) -> String {
values.map(name).joined(separator: ".")
}
// After
public func scopedName(_ values: [String]) -> String {
switch values.count {
case 0:
return ""
case 1:
return name(values[0])
case 2:
var scoped = name(values[0])
scoped += "."
scoped += name(values[1])
return scoped
default:
return values.map(name).joined(separator: ".")
}
}
None of this changes what a query renders to. The deterministic profiler recorded the effect on the two representative read cases from the #128 harness:
| Case | Render allocations, before to after | Combined allocations, before to after |
|---|---|---|
| Simple parameterized lookup | 249 to 212 (-14.9%) | 322 to 285 (-11.5%) |
| Multi-join read | 434 to 365 (-15.9%) | 562 to 493 (-12.3%) |
The same profiling run measured the #128 harness’s wall-clock median dropping roughly 13-17% across its four read, write, and decode cases, with byte-identical SQL output before and after. No absolute CI latency gate was added; the allocation counts are the primary, deterministic evidence, and the timing figures are directional, taken on a machine that had other test suites running concurrently.
One buffer, reused, on the incremental decode path
GRDBDatabaseDriverConnection.forEachRow used to allocate a fresh [XLSQLiteValue] array for every row, then wrap it again in Array(...):
// Before
let values = Array(row.databaseValues.map(\.sqliteDialectValue))
It now normalizes into one buffer that’s reused across the whole fetch, clearing and refilling it row to row instead of allocating a new array each time:
// After
var values: [XLSQLiteValue] = []
while let row = try cursor.next() {
values.removeAll(keepingCapacity: true)
values.reserveCapacity(row.count)
for databaseValue in row.databaseValues {
values.append(databaseValue.sqliteDialectValue)
}
// ... pass `values` to the row callback
}
Copy-on-write keeps this safe for callers that retain a row, such as the eager collectAllRows/collectFirstRow compatibility shims: retaining a row gives it its own storage the next time the buffer refills. The typed decode path itself still materializes no intermediate matrix, which preserves the bounded-memory guarantee from issue #248.
This removes a real per-row allocation, but it doesn’t close the issue #266 latency regression. Measured against the #250 harness, the regression turned out to be bound by per-row typed-decode compute, not by the allocation that was just removed, so this change is latency-neutral on that workload. The remaining latency work continues in issue #353.
A new diagnostic executable
swiftql-construction-profile is a new executable target added to the package (Benchmarks/Sources/SwiftQLConstructionProfile). It installs Darwin’s malloc_logger hook in-process and counts heap allocations issued during construction, rendering, and the combined construct-and-render phase, for the same two read queries the #128 harness measures:
swift run -c release swiftql-construction-profile \
--iterations 20000 --warmups 200 --samples 4000 \
--json /tmp/profile.json
It produced the allocation counts in the table above. It’s diagnostic evidence for issue #166, not part of SwiftQL’s runtime or a CI gate.
Migration
None. Every change in v1.4.6 is internal: the public API, entity metadata, and rendered SQL are all unchanged.
Back to the blog