Skip to content

Commit 5d35d58

Browse files
Merge branch 'diag/ae445-growth-fingerprint'
2 parents fcfebfe + bfcb4f6 commit 5d35d58

7 files changed

Lines changed: 327 additions & 21 deletions

File tree

Sources/AetherEngine/AetherEngine+Diagnostics.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ extension AetherEngine {
1717
/// #220 turned out to be, so the counter is polled at `triggerPollHz` and the zone walk runs once
1818
/// it climbs `triggerThresholdMB` above its running high-water. Pass `triggerPollHz: 0` for the
1919
/// plain 30 s census with no watcher.
20+
///
21+
/// `triggerCaptureCap` bounds how many of those walks are logged (`0` = uncapped). The default of
22+
/// twelve keeps a runaway from turning the log into a slideshow, but a session that climbs at a
23+
/// steady mux rate spends one capture per threshold climbed and reaches the cap minutes before
24+
/// the kill (AE#445, where the decisive final step survived only in the 30 s grid). Lift it when
25+
/// the shape being hunted is a steady climb rather than a single step.
2026
public nonisolated static func setLargeAllocationCensusEnabled(
2127
_ enabled: Bool,
2228
triggerThresholdMB: Int = 64,
23-
triggerPollHz: Double = 8
29+
triggerPollHz: Double = 8,
30+
triggerCaptureCap: Int = 12 // MallocBlockCensus.defaultTriggerCaptureCap, spelled out because that type is internal
2431
) {
2532
MallocBlockCensus.isEnabled = enabled
2633
// AE#445: the same switch, because they answer halves of one question. The malloc census
@@ -29,7 +36,8 @@ extension AetherEngine {
2936
VMRegionCensus.isEnabled = enabled
3037
if !enabled { VMRegionCensus.clearBaseline() }
3138
if enabled {
32-
MallocBlockCensus.startTriggerWatch(thresholdMB: triggerThresholdMB, pollHz: triggerPollHz)
39+
MallocBlockCensus.startTriggerWatch(thresholdMB: triggerThresholdMB, pollHz: triggerPollHz,
40+
captureCap: triggerCaptureCap)
3341
} else {
3442
MallocBlockCensus.stopTriggerWatch()
3543
}

Sources/AetherEngine/Diagnostics/MallocBlockCensus.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,73 @@ enum MallocBlockCensus {
101101
return Result(count: total, bytes: totalBytes, buckets: buckets, largest: largest)
102102
}
103103

104+
/// AE#445: the growth POLICY of the largest block, which names the allocator family holding it.
105+
///
106+
/// `bigExact` says one block is growing. Deciding WHOSE it is took that issue three rounds, and
107+
/// the ratio between two consecutive walks answers it more sharply than any size does, because
108+
/// each family grows by its own fixed factor and nothing else lands on one:
109+
///
110+
/// 1.25 Foundation `Data` (`__DataStorage._grow` adds `newLength >> 2` above 128 KB, rounds
111+
/// through `malloc_good_size`, and grows via `realloc`, so the region also carries the
112+
/// REALLOC tag)
113+
/// 1.5 FFmpeg's AVIO dynamic buffer (`dyn_buf_write`: `size += size / 2 + 1`)
114+
/// 1.0625 `av_fast_realloc` / `av_fast_malloc` (`min_size + min_size / 16 + 32`)
115+
/// 2 Swift `Array` / `ContiguousArray` / `String`
116+
///
117+
/// A block can climb several rungs between two walks, so a ratio is matched against the first
118+
/// four powers of each factor and the fewest-rung match wins. The census tracks sizes and not
119+
/// identities: if the top block changes hands the ratio belongs to no family, which is why an
120+
/// unrecognised value is printed bare instead of being rounded into the nearest story.
121+
static let growthFamilies: [(name: String, factor: Double)] = [
122+
("Data", 1.25),
123+
("dyn_buf", 1.5),
124+
("Array", 2.0),
125+
("av_fast_realloc", 1.0625),
126+
]
127+
128+
/// Relative tolerance per rung. The reporter's own ladder measured 1.2500 to 1.2502, i.e. 0.02%,
129+
/// and the nearest pair of neighbouring rungs across the table sits 2.4% apart, so this
130+
/// separates the families without needing them to be exact.
131+
static let growthTolerance = 0.004
132+
133+
/// Classify one growth step. Nil when the block did not grow.
134+
static func growthFamily(previousBytes: Int, currentBytes: Int) -> (ratio: Double, family: String?)? {
135+
guard previousBytes > 0, currentBytes > previousBytes else { return nil }
136+
let ratio = Double(currentBytes) / Double(previousBytes)
137+
for rungs in 1...4 {
138+
for family in growthFamilies {
139+
let step = pow(family.factor, Double(rungs))
140+
if abs(ratio - step) <= growthTolerance * step { return (ratio, family.name) }
141+
}
142+
}
143+
return (ratio, nil)
144+
}
145+
146+
/// Per-caller growth state. The 30 s memprobe walk and the 8 Hz trigger walk must not share a
147+
/// previous value: each would then report the ratio the OTHER's interval produced, which is a
148+
/// number about the sampler rather than about the buffer.
149+
final class GrowthTracker: @unchecked Sendable {
150+
private let lock = NSLock()
151+
private var previousLargest = 0
152+
153+
/// `bigGrowth=` fragment for this caller's cadence. "flat" is emitted deliberately: a line
154+
/// that only speaks while something grows cannot say that nothing did.
155+
func fragment(largest: Int) -> String {
156+
lock.lock()
157+
let previous = previousLargest
158+
previousLargest = largest
159+
lock.unlock()
160+
guard let step = MallocBlockCensus.growthFamily(previousBytes: previous, currentBytes: largest) else {
161+
return "bigGrowth=flat "
162+
}
163+
let family = step.family ?? "?"
164+
return String(format: "bigGrowth=%.4fx(%@) ", step.ratio, family)
165+
}
166+
}
167+
168+
static let memprobeGrowth = GrowthTracker()
169+
static let triggerGrowth = GrowthTracker()
170+
104171
/// memprobe fragment: total, summed size, and the largest buckets.
105172
/// Empty string when disabled, so the line shape is unchanged for normal sessions.
106173
static func probeFragment(topBuckets: Int = 4) -> String {
@@ -112,6 +179,7 @@ enum MallocBlockCensus {
112179
return "bigBlocks=\(result.count) bigMB=\(result.bytes / (1 << 20)) "
113180
+ "bigTop=\(top.isEmpty ? "none" : top) "
114181
+ "bigExact=\(exact.isEmpty ? "none" : exact) "
182+
+ memprobeGrowth.fragment(largest: result.largest.first ?? 0)
115183
}
116184
}
117185

Sources/AetherEngine/Diagnostics/MallocBlockCensusTrigger.swift

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ extension MallocBlockCensus {
2525
/// Emitted lines are capped: the escalation itself is informative (a step, then a bigger step),
2626
/// but a sustained explosion must not turn the log into a slideshow of zone walks while the
2727
/// device is already under pressure.
28-
static let maxTriggerCaptures = 12
28+
///
29+
/// AE#445 bought the exception the cap needed. A session that climbs at a steady mux rate spends
30+
/// one capture per threshold climbed, so twelve of them are gone long before the kill: the
31+
/// reporter's run hit the cap 4.4 minutes early and the decisive final step survived only in the
32+
/// 30 s grid. The cap is therefore configurable through `setLargeAllocationCensusEnabled`, and
33+
/// `0` means uncapped for exactly that shape.
34+
static let defaultTriggerCaptureCap = 12
35+
nonisolated(unsafe) private(set) static var triggerCaptureCap = defaultTriggerCaptureCap
2936

3037
nonisolated(unsafe) private static var watchQueue: DispatchQueue?
3138
nonisolated(unsafe) private static var watchTimer: DispatchSourceTimer?
@@ -53,10 +60,12 @@ extension MallocBlockCensus {
5360
/// HIGH-WATER to arm a capture, so a session that climbs gently spends one capture per threshold
5461
/// climbed while ordinary oscillation spends none. It should sit well above that gentle climb and
5562
/// far below the steps being hunted, which run from hundreds of MB to gigabytes.
56-
static func startTriggerWatch(thresholdMB: Int, pollHz: Double) {
63+
static func startTriggerWatch(thresholdMB: Int, pollHz: Double,
64+
captureCap: Int = defaultTriggerCaptureCap) {
5765
stopTriggerWatch()
5866
guard isEnabled, thresholdMB > 0, pollHz > 0 else { return }
5967
thresholdBytes = thresholdMB << 20
68+
triggerCaptureCap = max(0, captureCap)
6069
highWater = sizeInUse()
6170
triggerCaptures = 0
6271

@@ -71,7 +80,8 @@ extension MallocBlockCensus {
7180

7281
EngineLog.emit(
7382
"[AetherEngine] census trigger armed: threshold=\(thresholdMB)MB/poll "
74-
+ "poll=\(String(format: "%.1f", pollHz))Hz baseline=\(highWater >> 20)MB",
83+
+ "poll=\(String(format: "%.1f", pollHz))Hz baseline=\(highWater >> 20)MB "
84+
+ "cap=\(triggerCaptureCap == 0 ? "none" : String(triggerCaptureCap))",
7585
category: .engine
7686
)
7787
}
@@ -95,7 +105,7 @@ extension MallocBlockCensus {
95105
let previousHighWater = highWater
96106
defer { if now > highWater { highWater = now } }
97107
guard now >= previousHighWater + thresholdBytes,
98-
triggerCaptures < maxTriggerCaptures
108+
triggerCaptureCap == 0 || triggerCaptures < triggerCaptureCap
99109
else { return }
100110
triggerCaptures += 1
101111
let delta = now - previousHighWater
@@ -113,14 +123,15 @@ extension MallocBlockCensus {
113123
let exact = result.largest.map(String.init).joined(separator: ",")
114124
line += "bigBlocks=\(result.count) bigMB=\(result.bytes >> 20) "
115125
+ "bigTop=\(top.isEmpty ? "none" : top) "
116-
+ "bigExact=\(exact.isEmpty ? "none" : exact)"
126+
+ "bigExact=\(exact.isEmpty ? "none" : exact) "
127+
+ triggerGrowth.fragment(largest: result.largest.first ?? 0)
117128
} else {
118129
line += "census=unavailable"
119130
}
120131
EngineLog.emit(line, category: .engine)
121-
if triggerCaptures == maxTriggerCaptures {
132+
if triggerCaptureCap > 0, triggerCaptures == triggerCaptureCap {
122133
EngineLog.emit(
123-
"[AetherEngine] census trigger cap reached (\(maxTriggerCaptures)); "
134+
"[AetherEngine] census trigger cap reached (\(triggerCaptureCap)); "
124135
+ "further jumps are not captured, peak keeps updating",
125136
category: .engine
126137
)

Sources/aetherctl/LiveSpoolIO.swift

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ import AetherEngine
2828
/// buffer and allocates nothing, so that run measured the HARNESS rather than his case. The default
2929
/// arm is therefore the allocation-free one: what it measures is the engine. `--foundation-reader`
3030
/// restores the allocating arm, which is now a control for the pool rather than the subject.
31+
/// AE#445 round 3: the ingest-side carry, as a POSITIVE control.
32+
///
33+
/// The reporter's census named his growing block precisely: one `REALLOC`-tagged allocation on an
34+
/// exact x1.25 ladder whose content is every byte the session consumed. That factor is Foundation's,
35+
/// not libav's (`Data.__DataStorage._grow` adds `newLength >> 2` above 128 KB; `av_fast_realloc`
36+
/// adds a sixteenth, the AVIO dynamic buffer a half), so the block is a Swift `Data`, and the one
37+
/// `Data` shape that grows like that while its `count` stays small is a parse carry consumed from
38+
/// the front with `removeFirst`: that only advances the slice's lower bound, so the backing store
39+
/// keeps every byte below it and reallocs to fit the ever-rising upper bound. The engine paid for
40+
/// this lesson twice on its own readers (70430de, `ByteFIFO`) and re-bases with `subdata` in both.
41+
///
42+
/// `--host-carry removeFirst` puts that shape back into the harness on purpose, so the tool that
43+
/// measures the engine at ratio 0.00 can also produce the reporter's ratio 1.00 on demand and name
44+
/// the cause. `--host-carry subdata` is the same carry re-based, which is the fix.
45+
enum HostCarryTrim: String {
46+
case none, removeFirst, subdata
47+
}
48+
3149
final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
3250
private let path: String
3351
/// Foundation arm only. The POSIX arm reads through `fd` and never builds an object.
@@ -56,9 +74,19 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
5674
private(set) var maxLookbackBytes: Int64 = 0
5775
private(set) var seekCount: Int = 0
5876

77+
/// Host-side parse carry (see `HostCarryTrim`). Bounded in `count` by construction: everything
78+
/// but the partial trailing TS packet is consumed on every fill.
79+
private let carryTrim: HostCarryTrim
80+
private var carry = Data()
81+
private(set) var carryCount = 0
82+
/// The slice's lower bound, which is the whole tell: for a re-based carry it stays 0, for a
83+
/// `removeFirst` one it equals every byte ever consumed, and the backing store is that large.
84+
private(set) var carryStartIndex = 0
85+
5986
init(path: String, rateKbps: Int, reportsSize: Bool, wraps: Bool,
60-
foundationRead: Bool = false) throws {
87+
foundationRead: Bool = false, carryTrim: HostCarryTrim = .none) throws {
6188
self.path = path
89+
self.carryTrim = carryTrim
6290
let attrs = try FileManager.default.attributesOfItem(atPath: path)
6391
self.fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0
6492
if foundationRead {
@@ -127,6 +155,7 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
127155
}
128156
position += Int64(got)
129157
bytesRead += Int64(got)
158+
feedCarryLocked(buffer, count: got)
130159
lock.unlock()
131160
return Int32(got)
132161
}
@@ -136,6 +165,23 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
136165
}
137166
}
138167

168+
/// Push the delivered bytes through the carry and consume whole TS packets, which is what a
169+
/// PCR indexer on the ingest side does. Called under `lock`.
170+
private func feedCarryLocked(_ buffer: UnsafeMutablePointer<UInt8>, count: Int) {
171+
guard carryTrim != .none, count > 0 else { return }
172+
carry.append(buffer, count: count)
173+
let consumable = (carry.count / 188) * 188
174+
if consumable > 0 {
175+
switch carryTrim {
176+
case .removeFirst: carry.removeFirst(consumable)
177+
case .subdata: carry = carry.subdata(in: consumable..<carry.count)
178+
case .none: break
179+
}
180+
}
181+
carryCount = carry.count
182+
carryStartIndex = carry.startIndex
183+
}
184+
139185
/// One upstream burst. 32 KB is the size the engine's own file reader is measured in (#243) and
140186
/// is large enough that the read cadence is set by the source, not by the pacer's resolution.
141187
private static let releaseChunk: Int64 = 32 * 1024
@@ -165,7 +211,7 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
165211
func makeIndependentReader() -> IOReader? {
166212
try? PacedLiveSpoolIOReader(path: path, rateKbps: Int(rateBytesPerSecond * 8.0 / 1000.0),
167213
reportsSize: reportsSize, wraps: wraps,
168-
foundationRead: handle != nil)
214+
foundationRead: handle != nil, carryTrim: carryTrim)
169215
}
170216

171217
var discImageProbeEnabled: Bool { false }
@@ -184,20 +230,23 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
184230
/// graph. macOS has no jetsam, so the run cannot be killed here: the slope IS the finding.
185231
func runCustomLiveSpool(path: String, seconds: Double, rateKbps: Int, dvrWindow: Double?,
186232
reportsSize: Bool, wraps: Bool, mallocCensus: Bool,
187-
foundationReader: Bool = false) -> Int32 {
233+
foundationReader: Bool = false, carryTrim: HostCarryTrim = .none) -> Int32 {
188234
EngineLog.handler = { print($0) }
189235
if mallocCensus {
190-
AetherEngine.setLargeAllocationCensusEnabled(true, triggerThresholdMB: 32, triggerPollHz: 8)
236+
// Uncapped captures: a steady mux-rate climb spends one capture per threshold climbed, so the
237+
// default twelve are gone long before a long run ends (AE#445 hit the cap 4.4 min early).
238+
AetherEngine.setLargeAllocationCensusEnabled(true, triggerThresholdMB: 32, triggerPollHz: 8,
239+
triggerCaptureCap: 0)
191240
}
192241
print("aetherctl customio --live: \(path) (rate=\(rateKbps) kbit/s seconds=\(seconds) "
193242
+ "dvrWindow=\(dvrWindow.map { String($0) } ?? "nil") size=\(reportsSize ? "reported" : "unknown") "
194243
+ "wrap=\(wraps) census=\(mallocCensus) "
195-
+ "reader=\(foundationReader ? "foundation" : "posix"))")
244+
+ "reader=\(foundationReader ? "foundation" : "posix") hostCarry=\(carryTrim.rawValue))")
196245
let box = UncheckedBox<Int32?>(nil)
197246
Task { @MainActor in
198247
box.value = await customLiveSpoolRun(path: path, seconds: seconds, rateKbps: rateKbps,
199248
dvrWindow: dvrWindow, reportsSize: reportsSize, wraps: wraps,
200-
foundationReader: foundationReader)
249+
foundationReader: foundationReader, carryTrim: carryTrim)
201250
CFRunLoopStop(CFRunLoopGetMain())
202251
}
203252
CFRunLoopRun()
@@ -206,12 +255,13 @@ func runCustomLiveSpool(path: String, seconds: Double, rateKbps: Int, dvrWindow:
206255

207256
@MainActor
208257
private func customLiveSpoolRun(path: String, seconds: Double, rateKbps: Int, dvrWindow: Double?,
209-
reportsSize: Bool, wraps: Bool, foundationReader: Bool) async -> Int32 {
258+
reportsSize: Bool, wraps: Bool, foundationReader: Bool,
259+
carryTrim: HostCarryTrim) async -> Int32 {
210260
let reader: PacedLiveSpoolIOReader
211261
do {
212262
reader = try PacedLiveSpoolIOReader(path: path, rateKbps: rateKbps,
213263
reportsSize: reportsSize, wraps: wraps,
214-
foundationRead: foundationReader)
264+
foundationRead: foundationReader, carryTrim: carryTrim)
215265
} catch {
216266
print("VERDICT: reader init failed: \(error.localizedDescription)")
217267
return 1
@@ -255,8 +305,11 @@ private func customLiveSpoolRun(path: String, seconds: Double, rateKbps: Int, dv
255305
guard dt > 1 else { return "n/a" }
256306
return String(format: "%.2f", Double(fp - f.footprint) / dt)
257307
} ?? "n/a"
258-
print(String(format: " t=%.0fs state=%@ pos=%.2fs physFP=%dMB srcMB=%.1f growthMBps=%@",
259-
elapsed, "\(engine.state)", engine.currentTime, fp, srcMB, slope))
308+
let carryLine = carryTrim == .none ? "" : String(
309+
format: " carryCount=%dB carryStart=%.1fMB",
310+
reader.carryCount, Double(reader.carryStartIndex) / 1_048_576.0)
311+
print(String(format: " t=%.0fs state=%@ pos=%.2fs physFP=%dMB srcMB=%.1f growthMBps=%@%@",
312+
elapsed, "\(engine.state)", engine.currentTime, fp, srcMB, slope, carryLine))
260313
if case .error(let msg) = engine.state {
261314
print("VERDICT: session errored: \(msg)")
262315
engine.stop()
@@ -269,6 +322,16 @@ private func customLiveSpoolRun(path: String, seconds: Double, rateKbps: Int, dv
269322
print(String(format: "LOOKBACK: %d seeks, deepest reach-back %.1f MB behind the live edge "
270323
+ "(%.0f s of source at this rate)",
271324
reader.seekCount, lookbackMB, lookbackMB / srcMBps))
325+
if carryTrim != .none {
326+
// The verdict is the lower bound, not the count: a carry that starts at 0 owns exactly its
327+
// count, and one whose start tracks the consumed stream owns all of it.
328+
let startMB = Double(reader.carryStartIndex) / 1_048_576.0
329+
let reading = reader.carryStartIndex > (1 << 20)
330+
? "riding a backing store that large"
331+
: "re-based, so the allocation is the count"
332+
print(String(format: "HOST CARRY (%@): count=%dB, slice lower bound %.1f MB: %@.",
333+
carryTrim.rawValue, reader.carryCount, startMB, reading))
334+
}
272335
if let f = firstSample, let l = lastSample, l.t - f.t > 30 {
273336
let growth = Double(l.footprint - f.footprint) / (l.t - f.t)
274337
print(String(format: "VERDICT: physFP %d -> %d MB over %.0fs = %.2f MB/s "

0 commit comments

Comments
 (0)