sampledProfile.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import {CallTreeNode} from 'sentry/utils/profiling/callTreeNode';
  2. import {Frame} from './../frame';
  3. import {Profile} from './profile';
  4. import {createFrameIndex, resolveFlamegraphSamplesProfileIds} from './utils';
  5. function sortStacks(
  6. a: {stack: number[]; weight: number},
  7. b: {stack: number[]; weight: number}
  8. ) {
  9. const max = Math.max(a.stack.length, b.stack.length);
  10. for (let i = 0; i < max; i++) {
  11. if (a.stack[i] === undefined) {
  12. return -1;
  13. }
  14. if (b.stack[i] === undefined) {
  15. return 1;
  16. }
  17. if (a.stack[i] === b.stack[i]) {
  18. continue;
  19. }
  20. return a.stack[i] - b.stack[i];
  21. }
  22. return 0;
  23. }
  24. function stacksWithWeights(
  25. profile: Readonly<Profiling.SampledProfile>,
  26. profileIds: Readonly<string[][]> = [],
  27. frameFilter?: (i: number) => boolean
  28. ) {
  29. return profile.samples.map((stack, index) => {
  30. return {
  31. stack: frameFilter ? stack.filter(frameFilter) : stack,
  32. weight: profile.weights[index],
  33. aggregate_sample_duration: profile.sample_durations_ns?.[index] ?? 0,
  34. profileIds: profileIds[index],
  35. };
  36. });
  37. }
  38. function sortSamples(
  39. profile: Readonly<Profiling.SampledProfile>,
  40. profileIds: Readonly<string[][]> = [],
  41. frameFilter?: (i: number) => boolean
  42. ): {aggregate_sample_duration: number; stack: number[]; weight: number}[] {
  43. return stacksWithWeights(profile, profileIds, frameFilter).sort(sortStacks);
  44. }
  45. // We should try and remove these as we adopt our own profile format and only rely on the sampled format.
  46. export class SampledProfile extends Profile {
  47. static FromProfile(
  48. sampledProfile: Profiling.SampledProfile,
  49. frameIndex: ReturnType<typeof createFrameIndex>,
  50. options: {
  51. type: 'flamechart' | 'flamegraph';
  52. frameFilter?: (frame: Frame) => boolean;
  53. profileIds?: Readonly<string[]>;
  54. }
  55. ): Profile {
  56. const profile = new SampledProfile({
  57. duration: sampledProfile.endValue - sampledProfile.startValue,
  58. startedAt: sampledProfile.startValue,
  59. endedAt: sampledProfile.endValue,
  60. name: sampledProfile.name,
  61. unit: sampledProfile.unit,
  62. threadId: sampledProfile.threadID,
  63. type: options.type,
  64. });
  65. if (sampledProfile.samples.length !== sampledProfile.weights.length) {
  66. throw new Error(
  67. `Expected samples.length (${sampledProfile.samples.length}) to equal weights.length (${sampledProfile.weights.length})`
  68. );
  69. }
  70. let resolvedProfileIds: string[][] = [];
  71. if (
  72. options.type === 'flamegraph' &&
  73. sampledProfile.samples_profiles &&
  74. options.profileIds
  75. ) {
  76. resolvedProfileIds = resolveFlamegraphSamplesProfileIds(
  77. sampledProfile.samples_profiles,
  78. options.profileIds
  79. );
  80. }
  81. function resolveFrame(index) {
  82. const resolvedFrame = frameIndex[index];
  83. if (!resolvedFrame) {
  84. throw new Error(`Could not resolve frame ${index} in frame index`);
  85. }
  86. return resolvedFrame;
  87. }
  88. const samples =
  89. options.type === 'flamegraph'
  90. ? sortSamples(
  91. sampledProfile,
  92. resolvedProfileIds,
  93. options.frameFilter ? i => options.frameFilter!(resolveFrame(i)) : undefined
  94. )
  95. : stacksWithWeights(sampledProfile);
  96. // We process each sample in the profile while maintaining a resolved stack of frames.
  97. // There is a special case for GC frames where they are appended on top of the previos stack.
  98. // By reusing the stack buffer, we avoid creating a new array for each sample and for GC case,
  99. // also avoid re-resolving the previous stack frames as they are already in the buffer.
  100. // If we encounter multiple consecutive GC frames, we will merge them into a single sample
  101. // and sum their weights so that only one of them is processed.
  102. // After we resolve the stack, we call appendSampleWithWeight with the stack buffer, weight
  103. // and size of the stack to process. The size indicates how many items from the buffer we want
  104. // to process.
  105. const resolvedStack: Frame[] = new Array(256); // stack size limit
  106. let size = 0;
  107. let frame: Frame | null = null;
  108. for (let i = 0; i < samples.length; i++) {
  109. const stack = samples[i].stack;
  110. let weight = samples[i].weight;
  111. let aggregate_duration_ns = samples[i].aggregate_sample_duration;
  112. const isGCStack =
  113. options.type === 'flamechart' &&
  114. i > 0 &&
  115. // We check for size <= 2 because we have so far only seen node profiles
  116. // where GC is either marked as the root node or is directly under the root node.
  117. // There is a good chance that this logic will at some point live on the backend
  118. // and when that happens, we do not want to enter this case as the GC will already
  119. // be placed at the top of the previous stack and the new stack length will be > 2
  120. stack.length <= 2 &&
  121. frameIndex[stack[stack.length - 1]]?.name === '(garbage collector) [native code]';
  122. if (isGCStack) {
  123. // The next stack we will process will be the previous stack + our new gc frame.
  124. // We write the GC frame on top of the previous stack and set the size to the new stack length.
  125. frame = resolveFrame(stack[stack.length - 1]);
  126. if (frame) {
  127. resolvedStack[samples[i - 1].stack.length] =
  128. frameIndex[stack[stack.length - 1]];
  129. size += 1; // size of previous stack + new gc frame
  130. // Now collect all weights of all the consecutive gc frames and skip the samples
  131. while (
  132. samples[i + 1] &&
  133. // We check for size <= 2 because we have so far only seen node profiles
  134. // where GC is either marked as the root node or is directly under the root node.
  135. // There is a good chance that this logic will at some point live on the backend
  136. // and when that happens, we do not want to enter this case as the GC will already
  137. // be placed at the top of the previous stack and the new stack length will be > 2
  138. samples[i + 1].stack.length <= 2 &&
  139. frameIndex[samples[i + 1].stack[samples[i + 1].stack.length - 1]]?.name ===
  140. '(garbage collector) [native code]'
  141. ) {
  142. weight += samples[++i].weight;
  143. aggregate_duration_ns += samples[i].aggregate_sample_duration;
  144. }
  145. }
  146. } else {
  147. size = 0;
  148. // If we are using the current stack, then we need to resolve the frames,
  149. // else the processed frames will be the frames that were previously resolved
  150. for (let j = 0; j < stack.length; j++) {
  151. frame = resolveFrame(stack[j]);
  152. if (!frame) {
  153. continue;
  154. }
  155. resolvedStack[size++] = frame;
  156. }
  157. }
  158. profile.appendSampleWithWeight(
  159. resolvedStack,
  160. weight,
  161. size,
  162. resolvedProfileIds[i],
  163. aggregate_duration_ns
  164. );
  165. }
  166. return profile.build();
  167. }
  168. static Example = SampledProfile.FromProfile(
  169. {
  170. startValue: 0,
  171. endValue: 1000,
  172. name: 'Example sampled profile',
  173. threadID: 0,
  174. unit: 'millisecond',
  175. weights: [200, 200, 200, 200, 200],
  176. samples: [
  177. [0, 1, 2],
  178. [0, 1, 2, 3, 4, 5],
  179. [0, 1, 2, 3],
  180. [0, 1, 2, 3, 4],
  181. [0, 1],
  182. ],
  183. type: 'sampled',
  184. },
  185. {
  186. 0: new Frame({key: 0, name: 'f0', is_application: true}),
  187. 1: new Frame({key: 1, name: 'f1', is_application: true}),
  188. 2: new Frame({key: 2, name: 'f2'}),
  189. 3: new Frame({key: 3, name: 'f3'}),
  190. 4: new Frame({key: 4, name: 'f4', is_application: true}),
  191. 5: new Frame({key: 5, name: 'f5'}),
  192. },
  193. {type: 'flamechart'}
  194. );
  195. appendSampleWithWeight(
  196. stack: Frame[],
  197. weight: number,
  198. end: number,
  199. resolvedProfileIds?: string[],
  200. aggregate_duration_ns?: number
  201. ): void {
  202. // Keep track of discarded samples and ones that may have negative weights
  203. this.trackSampleStats(weight);
  204. // Ignore samples with 0 weight
  205. if (weight === 0) {
  206. return;
  207. }
  208. let node = this.callTree;
  209. const framesInStack: CallTreeNode[] = [];
  210. for (let i = 0; i < end; i++) {
  211. const frame = stack[i];
  212. const last = node.children[node.children.length - 1];
  213. // Find common frame between two stacks
  214. if (last && !last.isLocked() && last.frame === frame) {
  215. node = last;
  216. } else {
  217. const parent = node;
  218. node = new CallTreeNode(frame, node);
  219. parent.children.push(node);
  220. if (resolvedProfileIds) {
  221. this.callTreeNodeProfileIdMap.set(node, resolvedProfileIds);
  222. }
  223. }
  224. node.totalWeight += weight;
  225. node.aggregate_duration_ns += aggregate_duration_ns ?? 0;
  226. // TODO: This is On^2, because we iterate over all frames in the stack to check if our
  227. // frame is a recursive frame. We could do this in O(1) by keeping a map of frames in the stack
  228. // We check the stack in a top-down order to find the first recursive frame.
  229. let start = framesInStack.length - 1;
  230. while (start >= 0) {
  231. if (framesInStack[start].frame === node.frame) {
  232. // The recursion edge is bidirectional
  233. framesInStack[start].recursive = node;
  234. node.recursive = framesInStack[start];
  235. break;
  236. }
  237. start--;
  238. }
  239. framesInStack[i] = node;
  240. }
  241. node.selfWeight += weight;
  242. this.minFrameDuration = Math.min(weight, this.minFrameDuration);
  243. // Lock the stack node, so we make sure we dont mutate it in the future.
  244. // The samples should be ordered by timestamp when processed so we should never
  245. // iterate over them again in the future.
  246. for (const child of node.children) {
  247. child.lock();
  248. }
  249. node.frame.selfWeight += weight;
  250. for (const stackNode of framesInStack) {
  251. stackNode.frame.totalWeight += weight;
  252. stackNode.frame.aggregateDuration += aggregate_duration_ns ?? 0;
  253. stackNode.count++;
  254. }
  255. // If node is the same as the previous sample, add the weight to the previous sample
  256. if (node === this.samples[this.samples.length - 1]) {
  257. this.weights[this.weights.length - 1] += weight;
  258. } else {
  259. this.samples.push(node);
  260. this.weights.push(weight);
  261. }
  262. }
  263. build(): Profile {
  264. this.duration = Math.max(
  265. this.duration,
  266. this.weights.reduce((a, b) => a + b, 0)
  267. );
  268. // We had no frames with duration > 0, so set min duration to timeline duration
  269. // which effectively disables any zooming on the flamegraphs
  270. if (
  271. this.minFrameDuration === Number.POSITIVE_INFINITY ||
  272. this.minFrameDuration === 0
  273. ) {
  274. this.minFrameDuration = this.duration;
  275. }
  276. return this;
  277. }
  278. }