Skip to content

Commit 7f43cb4

Browse files
committed
perf: faster type-aware sorting and adaptive memory-based tab eviction
1 parent 48e706e commit 7f43cb4

File tree

9 files changed

+213
-28
lines changed

9 files changed

+213
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12-
- Reduce memory and CPU usage: flatten row storage, cache cell display values, lazy-load BLOB/TEXT columns
12+
- Improve performance: faster sorting, lower memory usage, adaptive tab eviction
1313

1414
## [0.23.0] - 2026-03-22
1515

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// MemoryPressureAdvisor.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
/// Advises on tab eviction budget based on system memory.
9+
enum MemoryPressureAdvisor {
10+
/// Returns the number of inactive tabs that should be kept in memory.
11+
/// Scales with total physical memory since macOS manages virtual memory pressure.
12+
static func budgetForInactiveTabs() -> Int {
13+
let totalBytes = ProcessInfo.processInfo.physicalMemory
14+
let gb: UInt64 = 1_073_741_824
15+
16+
if totalBytes >= 32 * gb {
17+
return 8
18+
} else if totalBytes >= 16 * gb {
19+
return 5
20+
} else if totalBytes >= 8 * gb {
21+
return 3
22+
} else {
23+
return 2
24+
}
25+
}
26+
27+
/// Rough estimate of a tab's memory footprint in bytes.
28+
/// Uses 64 bytes per cell as average (16B String struct + ~48B backing store).
29+
static func estimatedFootprint(rowCount: Int, columnCount: Int) -> Int {
30+
rowCount * columnCount * 64
31+
}
32+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// RowSortComparator.swift
3+
// TablePro
4+
//
5+
// Type-aware row value comparator for grid sorting.
6+
//
7+
8+
import Foundation
9+
10+
/// Type-aware row value comparator for grid sorting.
11+
/// Uses String.compare with .numeric option and type-specific fast paths for integer/decimal columns.
12+
enum RowSortComparator {
13+
static func compare(_ lhs: String, _ rhs: String, columnType: ColumnType?) -> ComparisonResult {
14+
if let columnType {
15+
switch columnType {
16+
case .integer:
17+
if let l = Int64(lhs), let r = Int64(rhs) {
18+
return l < r ? .orderedAscending : (l > r ? .orderedDescending : .orderedSame)
19+
}
20+
case .decimal:
21+
if let l = Double(lhs), let r = Double(rhs) {
22+
return l < r ? .orderedAscending : (l > r ? .orderedDescending : .orderedSame)
23+
}
24+
default:
25+
break
26+
}
27+
}
28+
return lhs.compare(rhs, options: [.numeric])
29+
}
30+
}

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,9 @@ struct MainEditorContentView: View {
435435
? (row1[sortCol.columnIndex] ?? "") : ""
436436
let val2 = sortCol.columnIndex < row2.count
437437
? (row2[sortCol.columnIndex] ?? "") : ""
438-
let result = val1.localizedStandardCompare(val2)
438+
let colType = sortCol.columnIndex < tab.columnTypes.count
439+
? tab.columnTypes[sortCol.columnIndex] : nil
440+
let result = RowSortComparator.compare(val1, val2, columnType: colType)
439441
if result == .orderedSame { continue }
440442
return sortCol.direction == .ascending
441443
? result == .orderedAscending

TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,23 @@ extension MainContentCoordinator {
132132
&& !$0.pendingChanges.hasChanges
133133
}
134134

135+
// Sort by oldest first, breaking ties by largest estimated footprint first
135136
let sorted = candidates.sorted {
136-
($0.lastExecutedAt ?? .distantFuture) < ($1.lastExecutedAt ?? .distantFuture)
137+
let t0 = $0.lastExecutedAt ?? .distantFuture
138+
let t1 = $1.lastExecutedAt ?? .distantFuture
139+
if t0 != t1 { return t0 < t1 }
140+
let size0 = MemoryPressureAdvisor.estimatedFootprint(
141+
rowCount: $0.rowBuffer.rows.count,
142+
columnCount: $0.rowBuffer.columns.count
143+
)
144+
let size1 = MemoryPressureAdvisor.estimatedFootprint(
145+
rowCount: $1.rowBuffer.rows.count,
146+
columnCount: $1.rowBuffer.columns.count
147+
)
148+
return size0 > size1
137149
}
138150

139-
let maxInactiveLoaded = 2
151+
let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs()
140152
guard sorted.count > maxInactiveLoaded else { return }
141153
let toEvict = sorted.dropLast(maxInactiveLoaded)
142154

TablePro/Views/Main/MainContentCoordinator.swift

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,7 @@ final class MainContentCoordinator {
11251125
let tabId = tab.id
11261126
let resultVersion = tab.resultVersion
11271127
let sortColumns = currentSort.columns
1128+
let colTypes = tab.columnTypes
11281129

11291130
if rows.count > 10_000 {
11301131
// Large dataset: sort on background thread to avoid UI freeze
@@ -1136,7 +1137,11 @@ final class MainContentCoordinator {
11361137

11371138
let sortStartTime = Date()
11381139
let task = Task.detached { [weak self] in
1139-
let sortedIndices = Self.multiColumnSortIndices(rows: rows, sortColumns: sortColumns)
1140+
let sortedIndices = Self.multiColumnSortIndices(
1141+
rows: rows,
1142+
sortColumns: sortColumns,
1143+
columnTypes: colTypes
1144+
)
11401145
let sortDuration = Date().timeIntervalSince(sortStartTime)
11411146

11421147
await MainActor.run { [weak self] in
@@ -1194,37 +1199,35 @@ final class MainContentCoordinator {
11941199
/// Returns an array of indices into the original `rows` array, sorted by the given columns.
11951200
nonisolated private static func multiColumnSortIndices(
11961201
rows: [[String?]],
1197-
sortColumns: [SortColumn]
1202+
sortColumns: [SortColumn],
1203+
columnTypes: [ColumnType] = []
11981204
) -> [Int] {
11991205
// Fast path: single-column sort avoids intermediate key array allocation
12001206
if sortColumns.count == 1 {
12011207
let col = sortColumns[0]
12021208
let colIndex = col.columnIndex
12031209
let ascending = col.direction == .ascending
1210+
let colType = colIndex < columnTypes.count ? columnTypes[colIndex] : nil
12041211
var indices = Array(0..<rows.count)
12051212
indices.sort { i1, i2 in
12061213
let v1 = colIndex < rows[i1].count ? (rows[i1][colIndex] ?? "") : ""
12071214
let v2 = colIndex < rows[i2].count ? (rows[i2][colIndex] ?? "") : ""
1208-
let cmp = v1.localizedStandardCompare(v2)
1215+
let cmp = RowSortComparator.compare(v1, v2, columnType: colType)
12091216
return ascending ? cmp == .orderedAscending : cmp == .orderedDescending
12101217
}
12111218
return indices
12121219
}
12131220

1214-
// Pre-extract sort keys for each row to avoid repeated access during comparison
1215-
let sortKeys: [[String]] = rows.map { row in
1216-
sortColumns.map { sortCol in
1217-
sortCol.columnIndex < row.count
1218-
? (row[sortCol.columnIndex] ?? "") : ""
1219-
}
1220-
}
1221-
12221221
var indices = Array(0..<rows.count)
12231222
indices.sort { i1, i2 in
1224-
let keys1 = sortKeys[i1]
1225-
let keys2 = sortKeys[i2]
1226-
for (colIdx, sortCol) in sortColumns.enumerated() {
1227-
let result = keys1[colIdx].localizedStandardCompare(keys2[colIdx])
1223+
let row1 = rows[i1]
1224+
let row2 = rows[i2]
1225+
for sortCol in sortColumns {
1226+
let v1 = sortCol.columnIndex < row1.count ? (row1[sortCol.columnIndex] ?? "") : ""
1227+
let v2 = sortCol.columnIndex < row2.count ? (row2[sortCol.columnIndex] ?? "") : ""
1228+
let colType = sortCol.columnIndex < columnTypes.count
1229+
? columnTypes[sortCol.columnIndex] : nil
1230+
let result = RowSortComparator.compare(v1, v2, columnType: colType)
12281231
if result == .orderedSame { continue }
12291232
return sortCol.direction == .ascending
12301233
? result == .orderedAscending
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// MemoryPressureAdvisorTests.swift
3+
// TableProTests
4+
//
5+
6+
import Testing
7+
@testable import TablePro
8+
9+
@Suite("MemoryPressureAdvisor")
10+
struct MemoryPressureAdvisorTests {
11+
@Test("budget returns positive value")
12+
func budgetPositive() {
13+
let budget = MemoryPressureAdvisor.budgetForInactiveTabs()
14+
#expect(budget >= 2)
15+
#expect(budget <= 8)
16+
}
17+
18+
@Test("memory estimation for typical tab")
19+
func typicalTabEstimate() {
20+
let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 1000, columnCount: 10)
21+
#expect(bytes == 640_000)
22+
}
23+
24+
@Test("memory estimation for empty tab")
25+
func emptyTabEstimate() {
26+
let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 0, columnCount: 10)
27+
#expect(bytes == 0)
28+
}
29+
30+
@Test("memory estimation for large tab")
31+
func largeTabEstimate() {
32+
let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 50_000, columnCount: 20)
33+
#expect(bytes == 64_000_000)
34+
}
35+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// RowSortComparatorTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
import Testing
8+
@testable import TablePro
9+
10+
@Suite("RowSortComparator")
11+
struct RowSortComparatorTests {
12+
@Test("numeric string ordering treats 10 > 2")
13+
func numericOrdering() {
14+
let result = RowSortComparator.compare("10", "2", columnType: nil)
15+
#expect(result == .orderedDescending)
16+
}
17+
18+
@Test("integer column uses Int64 comparison")
19+
func integerColumn() {
20+
let result = RowSortComparator.compare("-5", "3", columnType: .integer(rawType: "INT"))
21+
#expect(result == .orderedAscending)
22+
}
23+
24+
@Test("integer column with large values")
25+
func integerLargeValues() {
26+
let result = RowSortComparator.compare("999999999", "1000000000", columnType: .integer(rawType: "BIGINT"))
27+
#expect(result == .orderedAscending)
28+
}
29+
30+
@Test("integer column with non-numeric falls back to string")
31+
func integerFallback() {
32+
let result = RowSortComparator.compare("abc", "def", columnType: .integer(rawType: "INT"))
33+
#expect(result == .orderedAscending)
34+
}
35+
36+
@Test("decimal column uses Double comparison")
37+
func decimalColumn() {
38+
let result = RowSortComparator.compare("1.5", "2.3", columnType: .decimal(rawType: "DECIMAL"))
39+
#expect(result == .orderedAscending)
40+
}
41+
42+
@Test("decimal column negative values")
43+
func decimalNegative() {
44+
let result = RowSortComparator.compare("-1.5", "0.5", columnType: .decimal(rawType: "FLOAT"))
45+
#expect(result == .orderedAscending)
46+
}
47+
48+
@Test("equal values return orderedSame")
49+
func equalValues() {
50+
let result = RowSortComparator.compare("hello", "hello", columnType: nil)
51+
#expect(result == .orderedSame)
52+
}
53+
54+
@Test("empty strings are equal")
55+
func emptyStrings() {
56+
let result = RowSortComparator.compare("", "", columnType: nil)
57+
#expect(result == .orderedSame)
58+
}
59+
60+
@Test("text column uses numeric string comparison")
61+
func textColumn() {
62+
let result = RowSortComparator.compare("file2", "file10", columnType: .text(rawType: "VARCHAR"))
63+
#expect(result == .orderedAscending)
64+
}
65+
66+
@Test("nil column type uses numeric string comparison")
67+
func nilColumnType() {
68+
let result = RowSortComparator.compare("10", "2", columnType: nil)
69+
#expect(result == .orderedDescending)
70+
}
71+
}

TableProTests/Views/Results/DataGridPerformanceTests.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ struct SortKeyCachingTests {
2121
}
2222

2323
var indices1 = Array(0..<rows.count)
24-
indices1.sort { keys[$0].localizedStandardCompare(keys[$1]) == .orderedAscending }
24+
indices1.sort { keys[$0].compare(keys[$1], options: [.numeric]) == .orderedAscending }
2525

2626
var indices2 = Array(0..<rows.count)
2727
indices2.sort {
2828
let v1 = sortColumnIndex < rows[$0].count ? (rows[$0][sortColumnIndex] ?? "") : ""
2929
let v2 = sortColumnIndex < rows[$1].count ? (rows[$1][sortColumnIndex] ?? "") : ""
30-
return v1.localizedStandardCompare(v2) == .orderedAscending
30+
return RowSortComparator.compare(v1, v2, columnType: nil) == .orderedAscending
3131
}
3232

3333
#expect(indices1 == indices2)
@@ -42,17 +42,17 @@ struct SortKeyCachingTests {
4242
["Bob", "35"],
4343
]
4444

45-
let sortKeys: [[String]] = rows.map { row in
46-
[row[0] ?? "", row[1] ?? ""]
47-
}
48-
4945
var indices = Array(0..<rows.count)
5046
indices.sort { i1, i2 in
51-
let result = sortKeys[i1][0].localizedStandardCompare(sortKeys[i2][0])
47+
let v1 = rows[i1][0] ?? ""
48+
let v2 = rows[i2][0] ?? ""
49+
let result = RowSortComparator.compare(v1, v2, columnType: nil)
5250
if result != .orderedSame {
5351
return result == .orderedAscending
5452
}
55-
let result2 = sortKeys[i1][1].localizedStandardCompare(sortKeys[i2][1])
53+
let w1 = rows[i1][1] ?? ""
54+
let w2 = rows[i2][1] ?? ""
55+
let result2 = RowSortComparator.compare(w1, w2, columnType: nil)
5656
return result2 == .orderedDescending
5757
}
5858

@@ -78,7 +78,7 @@ struct SortKeyCachingTests {
7878
}
7979

8080
var indices = Array(0..<rows.count)
81-
indices.sort { keys[$0].localizedStandardCompare(keys[$1]) == .orderedAscending }
81+
indices.sort { keys[$0].compare(keys[$1], options: [.numeric]) == .orderedAscending }
8282

8383
// Empty string (nil) sorts first, then Alice, then Charlie
8484
#expect(rows[indices[0]][0] == nil)

0 commit comments

Comments
 (0)