Skip to content

Commit 352ffbd

Browse files
authored
Add per-thread labels to pprof output (#61)
Pprof samples now carry `thread_id` (numeric) and `thread_name` (string) labels. Identical stacks from different threads are kept as separate samples rather than merged. ### Callouts - `SampleAggregator`: new `ThreadInfo` and `SampleKey` types; aggregation is now keyed by stack + thread identity. - `PprofOutputRenderer`: populates `thread_id` and `thread_name` labels on every sample.
1 parent 4d60777 commit 352ffbd

9 files changed

Lines changed: 229 additions & 12 deletions

File tree

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ let package = Package(
173173
dependencies: [
174174
"ProfileRecorder",
175175
"_ProfileRecorderSampleConversion",
176+
"ProfileRecorderPprofFormat",
176177
"ProfileRecorderHelpers",
177178
.product(name: "Atomics", package: "swift-atomics"),
178179
.product(name: "NIO", package: "swift-nio"),

Sources/ProfileRecorderSampleConversion/OutputFormatRendering/Pprof.swift

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public struct PprofOutputRenderer: ProfileRecorderSampleConversionOutputRenderer
2929
let symbolisedStack = try sample.stack.map { frame in
3030
try symbolizer.symbolise(frame)
3131
}
32-
self.aggregator.add(symbolisedStack)
32+
let threadInfo = SampleAggregator.ThreadInfo(tid: sample.tid, name: sample.threadName)
33+
self.aggregator.add(symbolisedStack, threadInfo: threadInfo)
3334
return ByteBuffer()
3435
}
3536

@@ -47,6 +48,13 @@ public struct PprofOutputRenderer: ProfileRecorderSampleConversionOutputRenderer
4748
let cpuID = stringTable.addAndGetID("cpu", type: StringWithID.self)
4849
let nanosecondsID = stringTable.addAndGetID("nanoseconds", type: StringWithID.self)
4950

51+
// Thread label string table entries
52+
let threadIDKeyID = stringTable.addAndGetID("thread_id", type: StringWithID.self)
53+
let threadNameKeyID = stringTable.addAndGetID("thread_name", type: StringWithID.self)
54+
for sampleKey in self.aggregator.samples.keys {
55+
_ = stringTable.addAndGetID(sampleKey.threadInfo.name, type: StringWithID.self)
56+
}
57+
5058
let profile = Perftools_Profiles_Profile.with { profile in
5159
profile.location = self.aggregator.locations.values.sorted(by: { $0.id < $1.id }).map { location in
5260
.with {
@@ -70,10 +78,26 @@ public struct PprofOutputRenderer: ProfileRecorderSampleConversionOutputRenderer
7078
$0.unit = Int64(countID)
7179
}
7280
]
73-
profile.sample = self.aggregator.samples.map { (inSample, count) in
81+
profile.sample = self.aggregator.samples.map { (sampleKey, count) in
7482
Perftools_Profiles_Sample.with { outSample in
75-
outSample.locationID = inSample.map { UInt64($0) }
83+
outSample.locationID = sampleKey.locationIDs.map { UInt64($0) }
7684
outSample.value = [Int64(count)]
85+
outSample.label = [
86+
Perftools_Profiles_Label.with {
87+
$0.key = Int64(threadIDKeyID)
88+
$0.num = Int64(sampleKey.threadInfo.tid)
89+
},
90+
Perftools_Profiles_Label.with {
91+
if let entry = stringTable[sampleKey.threadInfo.name] {
92+
$0.key = Int64(threadNameKeyID)
93+
$0.str = Int64(entry.id)
94+
} else {
95+
assertionFailure(
96+
"thread name '\(sampleKey.threadInfo.name)' missing from string table"
97+
)
98+
}
99+
},
100+
]
77101
}
78102
}
79103
profile.periodType = Perftools_Profiles_ValueType.with {

Sources/ProfileRecorderSampleConversion/SampleAggregator.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,29 @@ struct SampleAggregator: Sendable {
4242
return new
4343
}
4444
}
45+
46+
struct ThreadInfo: Sendable, Hashable {
47+
var tid: Int
48+
var name: String
49+
}
50+
51+
struct SampleKey: Sendable, Hashable {
52+
var locationIDs: [Int]
53+
var threadInfo: ThreadInfo
54+
}
55+
4556
var locations: [UInt: Location] = [:]
4657
var functions: [String: Function] = [:]
47-
var samples: [[Int]: Int] = [:]
58+
var samples: [SampleKey: Int] = [:]
59+
60+
mutating func add(_ sample: [SymbolisedStackFrame], threadInfo: ThreadInfo) {
61+
let locationIDs = self.resolveLocationIDs(sample)
62+
let key = SampleKey(locationIDs: locationIDs, threadInfo: threadInfo)
63+
self.samples[key, default: 0] += 1
64+
}
4865

49-
mutating func add(_ sample: [SymbolisedStackFrame]) {
50-
let locationIDs = sample.compactMap { stackFrame -> Int? in
66+
private mutating func resolveLocationIDs(_ sample: [SymbolisedStackFrame]) -> [Int] {
67+
return sample.compactMap { stackFrame -> Int? in
5168
guard let address = stackFrame.allFrames.first?.address else {
5269
assertionFailure("empty stack? \(stackFrame)")
5370
return nil
@@ -67,7 +84,6 @@ struct SampleAggregator: Sendable {
6784
)
6885
return nextID
6986
}
70-
self.samples[locationIDs, default: 0] += 1
7187
}
7288
}
7389

Sources/ProfileRecorderSampleConversion/Symboliser.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ public struct SymbolizerConfiguration: Sendable {
257257
public var perfScriptOutputWithFileLineInformation: Bool
258258

259259
public static var `default`: SymbolizerConfiguration {
260-
return SymbolizerConfiguration(perfScriptOutputWithFileLineInformation: false)
260+
return SymbolizerConfiguration(
261+
perfScriptOutputWithFileLineInformation: false
262+
)
261263
}
262264
}
263265

Tests/ProfileRecorderSampleConversionTests/CachedSymbolizerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ final class CachedSymbolizerTests: XCTestCase {
6767
self.underlyingSymbolizer = FakeSymbolizer()
6868
try self.underlyingSymbolizer!.start()
6969
self.symbolizer = CachedSymbolizer(
70-
configuration: SymbolizerConfiguration(perfScriptOutputWithFileLineInformation: false),
70+
configuration: .default,
7171
symbolizer: self.underlyingSymbolizer!,
7272
dynamicLibraryMappings: [
7373
DynamicLibMapping(

Tests/ProfileRecorderSampleConversionTests/FlamegraphCollapsedTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ final class FlamegraphCollapsedScriptTests: XCTestCase {
151151
self.underlyingSymbolizer = FakeSymbolizer()
152152
try self.underlyingSymbolizer!.start()
153153
self.symbolizer = CachedSymbolizer(
154-
configuration: SymbolizerConfiguration(perfScriptOutputWithFileLineInformation: false),
154+
configuration: .default,
155155
symbolizer: self.underlyingSymbolizer!,
156156
dynamicLibraryMappings: [
157157
DynamicLibMapping(

Tests/ProfileRecorderSampleConversionTests/Helpers.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import _ProfileRecorderSampleConversion
1616
import Logging
17+
import NIO
18+
import ProfileRecorderPprofFormat
1719

1820
final class FakeSymbolizer: Symbolizer {
1921
var description: String {
@@ -44,3 +46,9 @@ final class FakeSymbolizer: Symbolizer {
4446
func shutdown() throws {
4547
}
4648
}
49+
50+
extension Perftools_Profiles_Profile {
51+
init(_ buffer: ByteBuffer) throws {
52+
try self.init(serializedBytes: Array(buffer.readableBytesView))
53+
}
54+
}

Tests/ProfileRecorderSampleConversionTests/PerfScriptTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ final class PerfScriptTests: XCTestCase {
117117
self.underlyingSymbolizer = FakeSymbolizer()
118118
try self.underlyingSymbolizer!.start()
119119
self.symbolizer = CachedSymbolizer(
120-
configuration: SymbolizerConfiguration(perfScriptOutputWithFileLineInformation: false),
120+
configuration: .default,
121121
symbolizer: self.underlyingSymbolizer!,
122122
dynamicLibraryMappings: [
123123
DynamicLibMapping(

Tests/ProfileRecorderSampleConversionTests/PprofTests.swift

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import Logging
1616
import XCTest
1717
import NIO
18+
import ProfileRecorderPprofFormat
1819

1920
@testable import _ProfileRecorderSampleConversion
2021

@@ -63,6 +64,171 @@ final class PprofTests: XCTestCase {
6364
XCTAssertEqual(ByteBuffer(), actual)
6465
}
6566

67+
func testPprofBasicDeserializesCorrectly() throws {
68+
var renderer = PprofOutputRenderer()
69+
let _ = try renderer.consumeSingleSample(
70+
Sample(
71+
sampleHeader: SampleHeader(pid: 1, tid: 2, name: "thread", timeSec: 4, timeNSec: 5),
72+
stack: [
73+
StackFrame(instructionPointer: 0, stackPointer: .max),
74+
StackFrame(instructionPointer: 0x2345, stackPointer: .max),
75+
StackFrame(instructionPointer: 0x2999, stackPointer: .max),
76+
]
77+
),
78+
configuration: .default,
79+
symbolizer: self.symbolizer
80+
)
81+
let output = try renderer.finalise(
82+
sampleConfiguration: SampleConfig(
83+
currentTimeSeconds: 0,
84+
currentTimeNanoseconds: 0,
85+
microSecondsBetweenSamples: 0,
86+
sampleCount: 0
87+
),
88+
configuration: .default,
89+
symbolizer: self.symbolizer
90+
)
91+
let profile = try Perftools_Profiles_Profile(output)
92+
XCTAssertEqual(profile.sample.count, 1)
93+
XCTAssertEqual(profile.sample[0].label.count, 2, "Should have thread_id and thread_name labels")
94+
}
95+
96+
func testPprofSameStackDifferentThreads_NotAggregated() throws {
97+
var renderer = PprofOutputRenderer()
98+
for tid in [10, 20] {
99+
let _ = try renderer.consumeSingleSample(
100+
Sample(
101+
sampleHeader: SampleHeader(pid: 1, tid: tid, name: "T\(tid)", timeSec: 0, timeNSec: 0),
102+
stack: [
103+
StackFrame(instructionPointer: 0x2345, stackPointer: .max)
104+
]
105+
),
106+
configuration: .default,
107+
symbolizer: self.symbolizer
108+
)
109+
}
110+
let output = try renderer.finalise(
111+
sampleConfiguration: SampleConfig(
112+
currentTimeSeconds: 0,
113+
currentTimeNanoseconds: 0,
114+
microSecondsBetweenSamples: 0,
115+
sampleCount: 0
116+
),
117+
configuration: .default,
118+
symbolizer: self.symbolizer
119+
)
120+
let profile = try Perftools_Profiles_Profile(output)
121+
XCTAssertEqual(profile.sample.count, 2, "Same stack from different threads should NOT be aggregated")
122+
}
123+
124+
func testPprofSameStackSameThread_Aggregated() throws {
125+
var renderer = PprofOutputRenderer()
126+
for _ in 0..<3 {
127+
let _ = try renderer.consumeSingleSample(
128+
Sample(
129+
sampleHeader: SampleHeader(pid: 1, tid: 42, name: "worker", timeSec: 0, timeNSec: 0),
130+
stack: [
131+
StackFrame(instructionPointer: 0x2345, stackPointer: .max)
132+
]
133+
),
134+
configuration: .default,
135+
symbolizer: self.symbolizer
136+
)
137+
}
138+
let output = try renderer.finalise(
139+
sampleConfiguration: SampleConfig(
140+
currentTimeSeconds: 0,
141+
currentTimeNanoseconds: 0,
142+
microSecondsBetweenSamples: 0,
143+
sampleCount: 0
144+
),
145+
configuration: .default,
146+
symbolizer: self.symbolizer
147+
)
148+
let profile = try Perftools_Profiles_Profile(output)
149+
XCTAssertEqual(profile.sample.count, 1, "Same stack from same thread should be aggregated")
150+
XCTAssertEqual(profile.sample[0].value, [3])
151+
}
152+
153+
func testPprofHasCorrectThreadLabels() throws {
154+
var renderer = PprofOutputRenderer()
155+
let _ = try renderer.consumeSingleSample(
156+
Sample(
157+
sampleHeader: SampleHeader(pid: 1, tid: 42, name: "main-thread", timeSec: 0, timeNSec: 0),
158+
stack: [
159+
StackFrame(instructionPointer: 0x2345, stackPointer: .max)
160+
]
161+
),
162+
configuration: .default,
163+
symbolizer: self.symbolizer
164+
)
165+
let output = try renderer.finalise(
166+
sampleConfiguration: SampleConfig(
167+
currentTimeSeconds: 0,
168+
currentTimeNanoseconds: 0,
169+
microSecondsBetweenSamples: 0,
170+
sampleCount: 0
171+
),
172+
configuration: .default,
173+
symbolizer: self.symbolizer
174+
)
175+
let profile = try Perftools_Profiles_Profile(output)
176+
XCTAssertEqual(profile.sample.count, 1)
177+
178+
let sample = profile.sample[0]
179+
XCTAssertEqual(sample.label.count, 2)
180+
181+
let threadIDLabel = sample.label.first { profile.stringTable[Int($0.key)] == "thread_id" }
182+
XCTAssertNotNil(threadIDLabel)
183+
XCTAssertEqual(threadIDLabel?.num, 42)
184+
185+
let threadNameLabel = sample.label.first { profile.stringTable[Int($0.key)] == "thread_name" }
186+
XCTAssertNotNil(threadNameLabel)
187+
XCTAssertEqual(profile.stringTable[Int(threadNameLabel!.str)], "main-thread")
188+
}
189+
190+
func testPprofThreadNameStringTableLookupIsSafe() throws {
191+
var renderer = PprofOutputRenderer()
192+
let threadNames = ["", "NIO-ELT-0-#3", "worker-pool-7"]
193+
for (index, name) in threadNames.enumerated() {
194+
let _ = try renderer.consumeSingleSample(
195+
Sample(
196+
sampleHeader: SampleHeader(
197+
pid: 1,
198+
tid: index + 1,
199+
name: name,
200+
timeSec: 0,
201+
timeNSec: 0
202+
),
203+
stack: [
204+
StackFrame(instructionPointer: 0x2345, stackPointer: .max)
205+
]
206+
),
207+
configuration: .default,
208+
symbolizer: self.symbolizer
209+
)
210+
}
211+
let output = try renderer.finalise(
212+
sampleConfiguration: SampleConfig(
213+
currentTimeSeconds: 0,
214+
currentTimeNanoseconds: 0,
215+
microSecondsBetweenSamples: 0,
216+
sampleCount: 0
217+
),
218+
configuration: .default,
219+
symbolizer: self.symbolizer
220+
)
221+
let profile = try Perftools_Profiles_Profile(output)
222+
XCTAssertEqual(profile.sample.count, 3)
223+
let emittedNames = Set(
224+
profile.sample.compactMap { sample -> String? in
225+
let label = sample.label.first { profile.stringTable[Int($0.key)] == "thread_name" }
226+
return label.map { profile.stringTable[Int($0.str)] }
227+
}
228+
)
229+
XCTAssertEqual(emittedNames, Set(threadNames))
230+
}
231+
66232
// MARK: - Setup/teardown
67233
override func setUpWithError() throws {
68234
self.logger = Logger(label: "\(Self.self)")
@@ -71,7 +237,7 @@ final class PprofTests: XCTestCase {
71237
self.underlyingSymbolizer = FakeSymbolizer()
72238
try self.underlyingSymbolizer!.start()
73239
self.symbolizer = CachedSymbolizer(
74-
configuration: SymbolizerConfiguration(perfScriptOutputWithFileLineInformation: false),
240+
configuration: .default,
75241
symbolizer: self.underlyingSymbolizer!,
76242
dynamicLibraryMappings: [
77243
DynamicLibMapping(

0 commit comments

Comments
 (0)