Profiler.cpp 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677
  1. // Jolt Physics Library (https://github.com/jrouwe/JoltPhysics)
  2. // SPDX-FileCopyrightText: 2021 Jorrit Rouwe
  3. // SPDX-License-Identifier: MIT
  4. #include <Jolt/Jolt.h>
  5. #include <Jolt/Core/Profiler.h>
  6. #include <Jolt/Core/Color.h>
  7. #include <Jolt/Core/StringTools.h>
  8. #include <Jolt/Core/QuickSort.h>
  9. JPH_SUPPRESS_WARNINGS_STD_BEGIN
  10. #include <fstream>
  11. JPH_SUPPRESS_WARNINGS_STD_END
  12. JPH_NAMESPACE_BEGIN
  13. #if defined(JPH_EXTERNAL_PROFILE) && defined(JPH_SHARED_LIBRARY)
  14. ProfileStartMeasurementFunction ProfileStartMeasurement = [](const char *, uint32, uint8 *) { };
  15. ProfileEndMeasurementFunction ProfileEndMeasurement = [](uint8 *) { };
  16. #elif defined(JPH_PROFILE_ENABLED)
  17. //////////////////////////////////////////////////////////////////////////////////////////
  18. // Profiler
  19. //////////////////////////////////////////////////////////////////////////////////////////
  20. Profiler *Profiler::sInstance = nullptr;
  21. #ifdef JPH_SHARED_LIBRARY
  22. static thread_local ProfileThread *sInstance = nullptr;
  23. ProfileThread *ProfileThread::sGetInstance()
  24. {
  25. return sInstance;
  26. }
  27. void ProfileThread::sSetInstance(ProfileThread *inInstance)
  28. {
  29. sInstance = inInstance;
  30. }
  31. #else
  32. thread_local ProfileThread *ProfileThread::sInstance = nullptr;
  33. #endif
  34. bool ProfileMeasurement::sOutOfSamplesReported = false;
  35. void Profiler::UpdateReferenceTime()
  36. {
  37. mReferenceTick = GetProcessorTickCount();
  38. mReferenceTime = std::chrono::high_resolution_clock::now();
  39. }
  40. uint64 Profiler::GetProcessorTicksPerSecond() const
  41. {
  42. uint64 ticks = GetProcessorTickCount();
  43. std::chrono::high_resolution_clock::time_point time = std::chrono::high_resolution_clock::now();
  44. return (ticks - mReferenceTick) * 1000000000ULL / std::chrono::duration_cast<std::chrono::nanoseconds>(time - mReferenceTime).count();
  45. }
  46. // This function assumes that none of the threads are active while we're dumping the profile,
  47. // otherwise there will be a race condition on mCurrentSample and the profile data.
  48. JPH_TSAN_NO_SANITIZE
  49. void Profiler::NextFrame()
  50. {
  51. std::lock_guard lock(mLock);
  52. if (mDump)
  53. {
  54. DumpInternal();
  55. mDump = false;
  56. }
  57. for (ProfileThread *t : mThreads)
  58. t->mCurrentSample = 0;
  59. UpdateReferenceTime();
  60. }
  61. void Profiler::Dump(const string_view &inTag)
  62. {
  63. mDump = true;
  64. mDumpTag = inTag;
  65. }
  66. void Profiler::AddThread(ProfileThread *inThread)
  67. {
  68. std::lock_guard lock(mLock);
  69. mThreads.push_back(inThread);
  70. }
  71. void Profiler::RemoveThread(ProfileThread *inThread)
  72. {
  73. std::lock_guard lock(mLock);
  74. Array<ProfileThread *>::iterator i = std::find(mThreads.begin(), mThreads.end(), inThread);
  75. JPH_ASSERT(i != mThreads.end());
  76. mThreads.erase(i);
  77. }
  78. void Profiler::sAggregate(int inDepth, uint32 inColor, ProfileSample *&ioSample, const ProfileSample *inEnd, Aggregators &ioAggregators, KeyToAggregator &ioKeyToAggregator)
  79. {
  80. // Store depth
  81. ioSample->mDepth = uint8(min(255, inDepth));
  82. // Update color
  83. if (ioSample->mColor == 0)
  84. ioSample->mColor = inColor;
  85. else
  86. inColor = ioSample->mColor;
  87. // Start accumulating totals
  88. uint64 cycles_this_with_children = ioSample->mEndCycle - ioSample->mStartCycle;
  89. // Loop over following samples until we find a sample that starts on or after our end
  90. ProfileSample *sample;
  91. for (sample = ioSample + 1; sample < inEnd && sample->mStartCycle < ioSample->mEndCycle; ++sample)
  92. {
  93. JPH_ASSERT(sample[-1].mStartCycle <= sample->mStartCycle);
  94. JPH_ASSERT(sample->mStartCycle >= ioSample->mStartCycle);
  95. JPH_ASSERT(sample->mEndCycle <= ioSample->mEndCycle);
  96. // Recurse and skip over the children of this child
  97. sAggregate(inDepth + 1, inColor, sample, inEnd, ioAggregators, ioKeyToAggregator);
  98. }
  99. // Find the aggregator for this name / filename pair
  100. Aggregator *aggregator;
  101. KeyToAggregator::iterator aggregator_idx = ioKeyToAggregator.find(ioSample->mName);
  102. if (aggregator_idx == ioKeyToAggregator.end())
  103. {
  104. // Not found, add to map and insert in array
  105. ioKeyToAggregator.try_emplace(ioSample->mName, ioAggregators.size());
  106. ioAggregators.emplace_back(ioSample->mName);
  107. aggregator = &ioAggregators.back();
  108. }
  109. else
  110. {
  111. // Found
  112. aggregator = &ioAggregators[aggregator_idx->second];
  113. }
  114. // Add the measurement to the aggregator
  115. aggregator->AccumulateMeasurement(cycles_this_with_children);
  116. // Update ioSample to the last child of ioSample
  117. JPH_ASSERT(sample[-1].mStartCycle <= ioSample->mEndCycle);
  118. JPH_ASSERT(sample >= inEnd || sample->mStartCycle >= ioSample->mEndCycle);
  119. ioSample = sample - 1;
  120. }
  121. void Profiler::DumpInternal()
  122. {
  123. // Freeze data from threads
  124. // Note that this is not completely thread safe: As a profile sample is added mCurrentSample is incremented
  125. // but the data is not written until the sample finishes. So if we dump the profile information while
  126. // some other thread is running, we may get some garbage information from the previous frame
  127. Threads threads;
  128. for (ProfileThread *t : mThreads)
  129. threads.push_back({ t->mThreadName, t->mSamples, t->mSamples + t->mCurrentSample });
  130. // Shift all samples so that the first sample is at zero
  131. uint64 min_cycle = 0xffffffffffffffffUL;
  132. for (const ThreadSamples &t : threads)
  133. if (t.mSamplesBegin < t.mSamplesEnd)
  134. min_cycle = min(min_cycle, t.mSamplesBegin[0].mStartCycle);
  135. for (const ThreadSamples &t : threads)
  136. for (ProfileSample *s = t.mSamplesBegin, *end = t.mSamplesEnd; s < end; ++s)
  137. {
  138. s->mStartCycle -= min_cycle;
  139. s->mEndCycle -= min_cycle;
  140. }
  141. // Determine tag of this profile
  142. String tag;
  143. if (mDumpTag.empty())
  144. {
  145. // Next sequence number
  146. static int number = 0;
  147. ++number;
  148. tag = ConvertToString(number);
  149. }
  150. else
  151. {
  152. // Take provided tag
  153. tag = mDumpTag;
  154. mDumpTag.clear();
  155. }
  156. // Aggregate data across threads
  157. Aggregators aggregators;
  158. KeyToAggregator key_to_aggregators;
  159. for (const ThreadSamples &t : threads)
  160. for (ProfileSample *s = t.mSamplesBegin, *end = t.mSamplesEnd; s < end; ++s)
  161. sAggregate(0, Color::sGetDistinctColor(0).GetUInt32(), s, end, aggregators, key_to_aggregators);
  162. // Dump as chart
  163. DumpChart(tag.c_str(), threads, key_to_aggregators, aggregators);
  164. }
  165. static String sHTMLEncode(const char *inString)
  166. {
  167. String str(inString);
  168. StringReplace(str, "<", "&lt;");
  169. StringReplace(str, ">", "&gt;");
  170. return str;
  171. }
  172. void Profiler::DumpChart(const char *inTag, const Threads &inThreads, const KeyToAggregator &inKeyToAggregators, const Aggregators &inAggregators)
  173. {
  174. // Open file
  175. std::ofstream f;
  176. f.open(StringFormat("profile_chart_%s.html", inTag).c_str(), std::ofstream::out | std::ofstream::trunc);
  177. if (!f.is_open())
  178. return;
  179. // Write header
  180. f << R"(<!DOCTYPE html>
  181. <html>
  182. <head>
  183. <title>Profile Chart</title>
  184. <style>
  185. html, body {
  186. padding: 0px;
  187. border: 0px;
  188. margin: 0px;
  189. width: 100%;
  190. height: 100%;
  191. overflow: hidden;
  192. }
  193. canvas {
  194. position: absolute;
  195. top: 10px;
  196. left: 10px;
  197. padding: 0px;
  198. border: 0px;
  199. margin: 0px;
  200. }
  201. #tooltip {
  202. font: Courier New;
  203. position: absolute;
  204. background-color: white;
  205. border: 1px;
  206. border-style: solid;
  207. border-color: black;
  208. pointer-events: none;
  209. padding: 5px;
  210. font: 14px Arial;
  211. visibility: hidden;
  212. height: auto;
  213. }
  214. .stat {
  215. color: blue;
  216. text-align: right;
  217. }
  218. </style>
  219. <script type="text/javascript">
  220. var canvas;
  221. var ctx;
  222. var tooltip;
  223. var min_scale;
  224. var scale;
  225. var offset_x = 0;
  226. var offset_y = 0;
  227. var size_y;
  228. var dragging = false;
  229. var previous_x = 0;
  230. var previous_y = 0;
  231. var bar_height = 15;
  232. var line_height = bar_height + 2;
  233. var thread_separation = 6;
  234. var thread_font_size = 12;
  235. var thread_font = thread_font_size + "px Arial";
  236. var bar_font_size = 10;
  237. var bar_font = bar_font_size + "px Arial";
  238. var end_cycle = 0;
  239. function drawChart()
  240. {
  241. ctx.clearRect(0, 0, canvas.width, canvas.height);
  242. ctx.lineWidth = 1;
  243. var y = offset_y;
  244. for (var t = 0; t < threads.length; t++)
  245. {
  246. // Check if thread has samples
  247. var thread = threads[t];
  248. if (thread.start.length == 0)
  249. continue;
  250. // Draw thread name
  251. y += thread_font_size;
  252. ctx.font = thread_font;
  253. ctx.fillStyle = "#000000";
  254. ctx.fillText(thread.thread_name, 0, y);
  255. y += thread_separation;
  256. // Draw outlines for each bar of samples
  257. ctx.fillStyle = "#c0c0c0";
  258. for (var d = 0; d <= thread.max_depth; d++)
  259. ctx.fillRect(0, y + d * line_height, canvas.width, bar_height);
  260. // Draw samples
  261. ctx.font = bar_font;
  262. for (var s = 0; s < thread.start.length; s++)
  263. {
  264. // Cull bar
  265. var rx = scale * (offset_x + thread.start[s]);
  266. if (rx > canvas.width) // right of canvas
  267. break;
  268. var rw = scale * thread.cycles[s];
  269. if (rw < 0.5) // less than half pixel, skip
  270. continue;
  271. if (rx + rw < 0) // left of canvas
  272. continue;
  273. // Draw bar
  274. var ry = y + line_height * thread.depth[s];
  275. ctx.fillStyle = thread.color[s];
  276. ctx.fillRect(rx, ry, rw, bar_height);
  277. ctx.strokeStyle = thread.darkened_color[s];
  278. ctx.strokeRect(rx, ry, rw, bar_height);
  279. // Get index in aggregated list
  280. var a = thread.aggregator[s];
  281. // Draw text
  282. if (rw > aggregated.name_width[a])
  283. {
  284. ctx.fillStyle = "#000000";
  285. ctx.fillText(aggregated.name[a], rx + (rw - aggregated.name_width[a]) / 2, ry + bar_height - 4);
  286. }
  287. }
  288. // Next line
  289. y += line_height * (1 + thread.max_depth) + thread_separation;
  290. }
  291. // Update size
  292. size_y = y - offset_y;
  293. }
  294. function drawTooltip(mouse_x, mouse_y)
  295. {
  296. var y = offset_y;
  297. for (var t = 0; t < threads.length; t++)
  298. {
  299. // Check if thread has samples
  300. var thread = threads[t];
  301. if (thread.start.length == 0)
  302. continue;
  303. // Thead name
  304. y += thread_font_size + thread_separation;
  305. // Draw samples
  306. for (var s = 0; s < thread.start.length; s++)
  307. {
  308. // Cull bar
  309. var rx = scale * (offset_x + thread.start[s]);
  310. if (rx > mouse_x)
  311. break;
  312. var rw = scale * thread.cycles[s];
  313. if (rx + rw < mouse_x)
  314. continue;
  315. var ry = y + line_height * thread.depth[s];
  316. if (mouse_y >= ry && mouse_y < ry + bar_height)
  317. {
  318. // Get index into aggregated list
  319. var a = thread.aggregator[s];
  320. // Found bar, fill in tooltip
  321. tooltip.style.left = (canvas.offsetLeft + mouse_x) + "px";
  322. tooltip.style.top = (canvas.offsetTop + mouse_y) + "px";
  323. tooltip.style.visibility = "visible";
  324. tooltip.innerHTML = aggregated.name[a] + "<br>"
  325. + "<table>"
  326. + "<tr><td>Time:</td><td class=\"stat\">" + (1000000 * thread.cycles[s] / cycles_per_second).toFixed(2) + " &micro;s</td></tr>"
  327. + "<tr><td>Start:</td><td class=\"stat\">" + (1000000 * thread.start[s] / cycles_per_second).toFixed(2) + " &micro;s</td></tr>"
  328. + "<tr><td>End:</td><td class=\"stat\">" + (1000000 * (thread.start[s] + thread.cycles[s]) / cycles_per_second).toFixed(2) + " &micro;s</td></tr>"
  329. + "<tr><td>Avg. Time:</td><td class=\"stat\">" + (1000000 * aggregated.cycles_per_frame[a] / cycles_per_second / aggregated.calls[a]).toFixed(2) + " &micro;s</td></tr>"
  330. + "<tr><td>Min Time:</td><td class=\"stat\">" + (1000000 * aggregated.min_cycles[a] / cycles_per_second).toFixed(2) + " &micro;s</td></tr>"
  331. + "<tr><td>Max Time:</td><td class=\"stat\">" + (1000000 * aggregated.max_cycles[a] / cycles_per_second).toFixed(2) + " &micro;s</td></tr>"
  332. + "<tr><td>Time / Frame:</td><td class=\"stat\">" + (1000000 * aggregated.cycles_per_frame[a] / cycles_per_second).toFixed(2) + " &micro;s</td></tr>"
  333. + "<tr><td>Calls:</td><td class=\"stat\">" + aggregated.calls[a] + "</td></tr>"
  334. + "</table>";
  335. return;
  336. }
  337. }
  338. // Next line
  339. y += line_height * (1 + thread.max_depth) + thread_separation;
  340. }
  341. // No bar found, hide tooltip
  342. tooltip.style.visibility = "hidden";
  343. }
  344. function onMouseDown(evt)
  345. {
  346. dragging = true;
  347. previous_x = evt.clientX, previous_y = evt.clientY;
  348. tooltip.style.visibility = "hidden";
  349. }
  350. function onMouseUp(evt)
  351. {
  352. dragging = false;
  353. }
  354. function clampMotion()
  355. {
  356. // Clamp horizontally
  357. var min_offset_x = canvas.width / scale - end_cycle;
  358. if (offset_x < min_offset_x)
  359. offset_x = min_offset_x;
  360. if (offset_x > 0)
  361. offset_x = 0;
  362. // Clamp vertically
  363. var min_offset_y = canvas.height - size_y;
  364. if (offset_y < min_offset_y)
  365. offset_y = min_offset_y;
  366. if (offset_y > 0)
  367. offset_y = 0;
  368. // Clamp scale
  369. if (scale < min_scale)
  370. scale = min_scale;
  371. var max_scale = 1000 * min_scale;
  372. if (scale > max_scale)
  373. scale = max_scale;
  374. }
  375. function onMouseMove(evt)
  376. {
  377. if (dragging)
  378. {
  379. // Calculate new offset
  380. offset_x += (evt.clientX - previous_x) / scale;
  381. offset_y += evt.clientY - previous_y;
  382. clampMotion();
  383. drawChart();
  384. }
  385. else
  386. drawTooltip(evt.clientX - canvas.offsetLeft, evt.clientY - canvas.offsetTop);
  387. previous_x = evt.clientX, previous_y = evt.clientY;
  388. }
  389. function onScroll(evt)
  390. {
  391. tooltip.style.visibility = "hidden";
  392. var old_scale = scale;
  393. if (evt.deltaY > 0)
  394. scale /= 1.1;
  395. else
  396. scale *= 1.1;
  397. clampMotion();
  398. // Ensure that event under mouse stays under mouse
  399. var x = previous_x - canvas.offsetLeft;
  400. offset_x += x / scale - x / old_scale;
  401. clampMotion();
  402. drawChart();
  403. }
  404. function darkenColor(color)
  405. {
  406. var i = parseInt(color.slice(1), 16);
  407. var r = i >> 16;
  408. var g = (i >> 8) & 0xff;
  409. var b = i & 0xff;
  410. r = Math.round(0.8 * r);
  411. g = Math.round(0.8 * g);
  412. b = Math.round(0.8 * b);
  413. i = (r << 16) + (g << 8) + b;
  414. return "#" + i.toString(16);
  415. }
  416. function startChart()
  417. {
  418. // Fetch elements
  419. canvas = document.getElementById('canvas');
  420. ctx = canvas.getContext("2d");
  421. tooltip = document.getElementById('tooltip');
  422. // Resize canvas to fill screen
  423. canvas.width = document.body.offsetWidth - 20;
  424. canvas.height = document.body.offsetHeight - 20;
  425. // Register mouse handlers
  426. canvas.onmousedown = onMouseDown;
  427. canvas.onmouseup = onMouseUp;
  428. canvas.onmouseout = onMouseUp;
  429. canvas.onmousemove = onMouseMove;
  430. canvas.onwheel = onScroll;
  431. for (var t = 0; t < threads.length; t++)
  432. {
  433. var thread = threads[t];
  434. // Calculate darkened colors
  435. thread.darkened_color = new Array(thread.color.length);
  436. for (var s = 0; s < thread.color.length; s++)
  437. thread.darkened_color[s] = darkenColor(thread.color[s]);
  438. // Calculate max depth and end cycle
  439. thread.max_depth = 0;
  440. for (var s = 0; s < thread.start.length; s++)
  441. {
  442. thread.max_depth = Math.max(thread.max_depth, thread.depth[s]);
  443. end_cycle = Math.max(end_cycle, thread.start[s] + thread.cycles[s]);
  444. }
  445. }
  446. // Calculate width of name strings
  447. ctx.font = bar_font;
  448. aggregated.name_width = new Array(aggregated.name.length);
  449. for (var a = 0; a < aggregated.name.length; a++)
  450. aggregated.name_width[a] = ctx.measureText(aggregated.name[a]).width;
  451. // Store scale properties
  452. min_scale = canvas.width / end_cycle;
  453. scale = min_scale;
  454. drawChart();
  455. }
  456. </script>
  457. </head>
  458. <body onload="startChart();">
  459. <script type="text/javascript">
  460. )";
  461. // Get cycles per second
  462. uint64 cycles_per_second = GetProcessorTicksPerSecond();
  463. f << "var cycles_per_second = " << cycles_per_second << ";\n";
  464. // Dump samples
  465. f << "var threads = [\n";
  466. bool first_thread = true;
  467. for (const ThreadSamples &t : inThreads)
  468. {
  469. if (!first_thread)
  470. f << ",\n";
  471. first_thread = false;
  472. f << "{\nthread_name: \"" << t.mThreadName << "\",\naggregator: [";
  473. bool first = true;
  474. for (const ProfileSample *s = t.mSamplesBegin, *end = t.mSamplesEnd; s < end; ++s)
  475. {
  476. if (!first)
  477. f << ",";
  478. first = false;
  479. f << inKeyToAggregators.find(s->mName)->second;
  480. }
  481. f << "],\ncolor: [";
  482. first = true;
  483. for (const ProfileSample *s = t.mSamplesBegin, *end = t.mSamplesEnd; s < end; ++s)
  484. {
  485. if (!first)
  486. f << ",";
  487. first = false;
  488. Color c(s->mColor);
  489. f << StringFormat("\"#%02x%02x%02x\"", c.r, c.g, c.b);
  490. }
  491. f << "],\nstart: [";
  492. first = true;
  493. for (const ProfileSample *s = t.mSamplesBegin, *end = t.mSamplesEnd; s < end; ++s)
  494. {
  495. if (!first)
  496. f << ",";
  497. first = false;
  498. f << s->mStartCycle;
  499. }
  500. f << "],\ncycles: [";
  501. first = true;
  502. for (const ProfileSample *s = t.mSamplesBegin, *end = t.mSamplesEnd; s < end; ++s)
  503. {
  504. if (!first)
  505. f << ",";
  506. first = false;
  507. f << s->mEndCycle - s->mStartCycle;
  508. }
  509. f << "],\ndepth: [";
  510. first = true;
  511. for (const ProfileSample *s = t.mSamplesBegin, *end = t.mSamplesEnd; s < end; ++s)
  512. {
  513. if (!first)
  514. f << ",";
  515. first = false;
  516. f << int(s->mDepth);
  517. }
  518. f << "]\n}";
  519. }
  520. // Dump aggregated data
  521. f << "];\nvar aggregated = {\nname: [";
  522. bool first = true;
  523. for (const Aggregator &a : inAggregators)
  524. {
  525. if (!first)
  526. f << ",";
  527. first = false;
  528. String name = "\"" + sHTMLEncode(a.mName) + "\"";
  529. f << name;
  530. }
  531. f << "],\ncalls: [";
  532. first = true;
  533. for (const Aggregator &a : inAggregators)
  534. {
  535. if (!first)
  536. f << ",";
  537. first = false;
  538. f << a.mCallCounter;
  539. }
  540. f << "],\nmin_cycles: [";
  541. first = true;
  542. for (const Aggregator &a : inAggregators)
  543. {
  544. if (!first)
  545. f << ",";
  546. first = false;
  547. f << a.mMinCyclesInCallWithChildren;
  548. }
  549. f << "],\nmax_cycles: [";
  550. first = true;
  551. for (const Aggregator &a : inAggregators)
  552. {
  553. if (!first)
  554. f << ",";
  555. first = false;
  556. f << a.mMaxCyclesInCallWithChildren;
  557. }
  558. f << "],\ncycles_per_frame: [";
  559. first = true;
  560. for (const Aggregator &a : inAggregators)
  561. {
  562. if (!first)
  563. f << ",";
  564. first = false;
  565. f << a.mTotalCyclesInCallWithChildren;
  566. }
  567. // Write footer
  568. f << R"(]};
  569. </script>
  570. <canvas id="canvas"></canvas>
  571. <div id="tooltip"></div>
  572. </tbody></table></body></html>)";
  573. }
  574. #endif // JPH_PROFILE_ENABLED
  575. JPH_NAMESPACE_END