diff --git a/devops/scripts/benchmarks/benches/base.py b/devops/scripts/benchmarks/benches/base.py index cee03c6348985..d932e0f7390fe 100644 --- a/devops/scripts/benchmarks/benches/base.py +++ b/devops/scripts/benchmarks/benches/base.py @@ -7,12 +7,22 @@ import shutil import subprocess from pathlib import Path +from enum import Enum from utils.result import BenchmarkMetadata, BenchmarkTag, Result from options import options from utils.utils import download, run from abc import ABC, abstractmethod from utils.unitrace import get_unitrace from utils.logger import log +from utils.flamegraph import get_flamegraph + + +class TracingType(Enum): + """Enumeration of available tracing types.""" + + UNITRACE = "unitrace" + FLAMEGRAPH = "flamegraph" + benchmark_tags = [ BenchmarkTag("SYCL", "Benchmark uses SYCL runtime"), @@ -62,9 +72,10 @@ def enabled(self) -> bool: By default, it returns True, but can be overridden to disable a benchmark.""" return True - def traceable(self) -> bool: - """Returns whether this benchmark should be traced by Unitrace. - By default, it returns True, but can be overridden to disable tracing for a benchmark. + def traceable(self, tracing_type: TracingType) -> bool: + """Returns whether this benchmark should be traced by the specified tracing method. + By default, it returns True for all tracing types, but can be overridden + to disable specific tracing methods for a benchmark. """ return True @@ -77,12 +88,15 @@ def teardown(self): pass @abstractmethod - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: """Execute the benchmark with the given environment variables. Args: env_vars: Environment variables to use when running the benchmark. run_unitrace: Whether to run benchmark under Unitrace. + run_flamegraph: Whether to run benchmark under FlameGraph. Returns: A list of Result objects with the benchmark results. @@ -113,6 +127,8 @@ def run_bench( use_stdout=True, run_unitrace=False, extra_unitrace_opt=None, + run_flamegraph=False, + extra_perf_opt=None, # VERIFY ): env_vars = env_vars.copy() if options.ur is not None: @@ -125,7 +141,7 @@ def run_bench( ld_libraries = options.extra_ld_libraries.copy() ld_libraries.extend(ld_library) - if self.traceable() and run_unitrace: + if self.traceable(TracingType.UNITRACE) and run_unitrace: if extra_unitrace_opt is None: extra_unitrace_opt = [] unitrace_output, command = get_unitrace().setup( @@ -147,9 +163,41 @@ def run_bench( get_unitrace().cleanup(options.benchmark_cwd, unitrace_output) raise - if self.traceable() and run_unitrace: + if self.traceable(TracingType.UNITRACE) and run_unitrace: get_unitrace().handle_output(unitrace_output) + # flamegraph run + + ld_libraries = options.extra_ld_libraries.copy() + ld_libraries.extend(ld_library) + + perf_data_file = None + if self.traceable(TracingType.FLAMEGRAPH) and run_flamegraph: + if extra_perf_opt is None: + extra_perf_opt = [] + perf_data_file, command = get_flamegraph().setup( + self.name(), command, extra_perf_opt + ) + log.debug(f"FlameGraph perf data: {perf_data_file}") + log.debug(f"FlameGraph command: {' '.join(command)}") + + try: + result = run( + command=command, + env_vars=env_vars, + add_sycl=add_sycl, + cwd=options.benchmark_cwd, + ld_library=ld_libraries, + ) + except subprocess.CalledProcessError: + if run_flamegraph and perf_data_file: + get_flamegraph().cleanup(options.benchmark_cwd, perf_data_file) + raise + + if self.traceable(TracingType.FLAMEGRAPH) and run_flamegraph and perf_data_file: + svg_file = get_flamegraph().handle_output(self.name(), perf_data_file) + log.info(f"FlameGraph generated: {svg_file}") + if use_stdout: return result.stdout.decode() else: diff --git a/devops/scripts/benchmarks/benches/benchdnn.py b/devops/scripts/benchmarks/benches/benchdnn.py index 6c00ed9771135..6d202ed3a8e4b 100644 --- a/devops/scripts/benchmarks/benches/benchdnn.py +++ b/devops/scripts/benchmarks/benches/benchdnn.py @@ -132,7 +132,9 @@ def setup(self): if not self.bench_bin.exists(): raise FileNotFoundError(f"Benchmark binary not found: {self.bench_bin}") - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_flamegraph: bool = False, run_unitrace: bool = False + ) -> list[Result]: command = [ str(self.bench_bin), *self.bench_args.split(), @@ -153,6 +155,7 @@ def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: use_stdout=True, run_unitrace=run_unitrace, extra_unitrace_opt=["--chrome-dnn-logging"], + run_flamegraph=run_flamegraph, ) result_value = self._extract_time(output) diff --git a/devops/scripts/benchmarks/benches/benchdnn_list.py b/devops/scripts/benchmarks/benches/benchdnn_list.py index 53721ec1fa178..18d03fac04340 100644 --- a/devops/scripts/benchmarks/benches/benchdnn_list.py +++ b/devops/scripts/benchmarks/benches/benchdnn_list.py @@ -14,29 +14,29 @@ # the final choice of benchmarks to run, used in CI and other environments benches_final_set = [ - [ - "sum", - "f16-1", - "--sdt=f16:f16:f16 --stag=abx:abx:abx --scales=1.25:3:0.5 16x2x6x4x3", - False, # Do not run graph for this benchmark - ], - [ - "sum", - "f16-2", - "--reset --ddt=f16 \ - --sdt=f16:f16:f16:f16:f16:f16:f16:f16:f16:f16 \ - --stag=abx:aBx16b:ABx16a16b:ABcd16b16a:BAcd16a16b:BAcd16b16a:aBCd16b16c:aBCd16c16b:aCBd16b16c:aCBd16c16b \ - --dtag=abx,aBx16b,ABx16a16b,ABcd16b16a,BAcd16a16b,BAcd16b16a,aBCd16b16c,aBCd16c16b,aCBd16b16c,aCBd16c16b \ - --scales=1.25:3:0.5:2:0.5:2:0.5:2:0.5:2 \ - 16x32x48x5", - False, # Do not run graph for this benchmark - ], - [ - "sum", - "f32-1", - "--sdt=bf16:bf16:bf16 --stag=abx:abx:abx --scales=0.5:2:0.5 16x2x6x4x3", - False, # Do not run graph for this benchmark - ], + # [ + # "sum", + # "f16-1", + # "--sdt=f16:f16:f16 --stag=abx:abx:abx --scales=1.25:3:0.5 16x2x6x4x3", + # False, # Do not run graph for this benchmark + # ], + # [ + # "sum", + # "f16-2", + # "--reset --ddt=f16 \ + # --sdt=f16:f16:f16:f16:f16:f16:f16:f16:f16:f16 \ + # --stag=abx:aBx16b:ABx16a16b:ABcd16b16a:BAcd16a16b:BAcd16b16a:aBCd16b16c:aBCd16c16b:aCBd16b16c:aCBd16c16b \ + # --dtag=abx,aBx16b,ABx16a16b,ABcd16b16a,BAcd16a16b,BAcd16b16a,aBCd16b16c,aBCd16c16b,aCBd16b16c,aCBd16c16b \ + # --scales=1.25:3:0.5:2:0.5:2:0.5:2:0.5:2 \ + # 16x32x48x5", + # False, # Do not run graph for this benchmark + # ], + # [ + # "sum", + # "f32-1", + # "--sdt=bf16:bf16:bf16 --stag=abx:abx:abx --scales=0.5:2:0.5 16x2x6x4x3", + # False, # Do not run graph for this benchmark + # ], [ "sum", "f32-2", diff --git a/devops/scripts/benchmarks/benches/compute.py b/devops/scripts/benchmarks/benches/compute.py index 27b0f3c0afe17..63d8989fce2e6 100644 --- a/devops/scripts/benchmarks/benches/compute.py +++ b/devops/scripts/benchmarks/benches/compute.py @@ -339,7 +339,9 @@ def explicit_group(self): def description(self) -> str: return "" - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: command = [ f"{self.benchmark_bin}", f"--test={self.test}", @@ -351,9 +353,7 @@ def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: env_vars.update(self.extra_env_vars()) result = self.run_bench( - command, - env_vars, - run_unitrace=run_unitrace, + command, env_vars, run_unitrace=run_unitrace, run_flamegraph=run_flamegraph ) parsed_results = self.parse_output(result) ret = [] diff --git a/devops/scripts/benchmarks/benches/gromacs.py b/devops/scripts/benchmarks/benches/gromacs.py index af64c16699118..49e602b96636b 100644 --- a/devops/scripts/benchmarks/benches/gromacs.py +++ b/devops/scripts/benchmarks/benches/gromacs.py @@ -169,7 +169,9 @@ def setup(self): ld_library=self.suite.oneapi.ld_libraries(), ) - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_flamegraph: bool = False, run_unitrace: bool = False + ) -> list[Result]: model_dir = self.grappa_dir / self.model env_vars.update({"SYCL_CACHE_PERSISTENT": "1"}) @@ -209,6 +211,7 @@ def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: use_stdout=False, ld_library=self.suite.oneapi.ld_libraries(), run_unitrace=run_unitrace, + run_flamegraph=run_flamegraph, ) if not self._validate_correctness(options.benchmark_cwd + "/md.log"): diff --git a/devops/scripts/benchmarks/benches/llamacpp.py b/devops/scripts/benchmarks/benches/llamacpp.py index c02e3e2335414..a78d8961e5c77 100644 --- a/devops/scripts/benchmarks/benches/llamacpp.py +++ b/devops/scripts/benchmarks/benches/llamacpp.py @@ -115,7 +115,9 @@ def get_tags(self): def lower_is_better(self): return False - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: command = [ f"{self.benchmark_bin}", "--output", @@ -145,6 +147,7 @@ def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: env_vars, ld_library=self.bench.oneapi.ld_libraries(), run_unitrace=run_unitrace, + run_flamegraph=run_flamegraph, ) parsed = self.parse_output(result) results = [] diff --git a/devops/scripts/benchmarks/benches/syclbench.py b/devops/scripts/benchmarks/benches/syclbench.py index fc76287ed214d..0769700cf8280 100644 --- a/devops/scripts/benchmarks/benches/syclbench.py +++ b/devops/scripts/benchmarks/benches/syclbench.py @@ -137,7 +137,9 @@ def setup(self): self.directory, "sycl-bench-build", self.bench_name ) - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: self.outputfile = os.path.join(self.bench.directory, self.test + ".csv") command = [ @@ -152,9 +154,7 @@ def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: # no output to stdout, all in outputfile self.run_bench( - command, - env_vars, - run_unitrace=run_unitrace, + command, env_vars, run_unitrace=run_unitrace, run_flamegraph=run_flamegraph ) with open(self.outputfile, "r") as f: diff --git a/devops/scripts/benchmarks/benches/test.py b/devops/scripts/benchmarks/benches/test.py index 88e5dbff7fb08..9298137314234 100644 --- a/devops/scripts/benchmarks/benches/test.py +++ b/devops/scripts/benchmarks/benches/test.py @@ -88,7 +88,9 @@ def notes(self) -> str: def unstable(self) -> str: return self.unstable_text - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: random_value = self.value + random.uniform(-1 * (self.diff), self.diff) return [ Result( diff --git a/devops/scripts/benchmarks/benches/umf.py b/devops/scripts/benchmarks/benches/umf.py index 961adacc10f7c..ce275f6b7e2e3 100644 --- a/devops/scripts/benchmarks/benches/umf.py +++ b/devops/scripts/benchmarks/benches/umf.py @@ -137,7 +137,9 @@ def get_names_of_benchmarks_to_be_run(self, command, env_vars): return all_names - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: command = [f"{self.benchmark_bin}"] all_names = self.get_names_of_benchmarks_to_be_run(command, env_vars) @@ -156,6 +158,7 @@ def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: add_sycl=False, ld_library=[self.umf_lib], run_unitrace=run_unitrace, + run_flamegraph=run_flamegraph, ) parsed = self.parse_output(result) diff --git a/devops/scripts/benchmarks/benches/velocity.py b/devops/scripts/benchmarks/benches/velocity.py index a16c9d1896838..911c5f66a4e0a 100644 --- a/devops/scripts/benchmarks/benches/velocity.py +++ b/devops/scripts/benchmarks/benches/velocity.py @@ -130,7 +130,9 @@ def description(self) -> str: def get_tags(self): return ["SYCL", "application"] - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: env_vars.update(self.extra_env_vars()) command = [ @@ -143,6 +145,7 @@ def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: env_vars, ld_library=self.ld_libraries(), run_unitrace=run_unitrace, + run_flamegraph=run_flamegraph, ) return [ @@ -287,7 +290,9 @@ class QuickSilver(VelocityBase): def __init__(self, vb: VelocityBench): super().__init__("QuickSilver", "qs", vb, "MMS/CTT") - def run(self, env_vars, run_unitrace: bool = False) -> list[Result]: + def run( + self, env_vars, run_unitrace: bool = False, run_flamegraph: bool = False + ) -> list[Result]: # TODO: fix the crash in QuickSilver when UR_L0_USE_IMMEDIATE_COMMANDLISTS=0 if ( "UR_L0_USE_IMMEDIATE_COMMANDLISTS" in env_vars diff --git a/devops/scripts/benchmarks/html/data.js b/devops/scripts/benchmarks/html/data.js index eaa5dfdf8b375..f7f135803b4e2 100644 --- a/devops/scripts/benchmarks/html/data.js +++ b/devops/scripts/benchmarks/html/data.js @@ -8,3 +8,8 @@ benchmarkRuns = []; defaultCompareNames = []; + +benchmarkMetadata = {}; + +benchmarkTags = {}; + diff --git a/devops/scripts/benchmarks/html/index.html b/devops/scripts/benchmarks/html/index.html index 43c229fb67dd9..b1771d5637d78 100644 --- a/devops/scripts/benchmarks/html/index.html +++ b/devops/scripts/benchmarks/html/index.html @@ -57,6 +57,10 @@

Display Options

Include archived runs + diff --git a/devops/scripts/benchmarks/html/scripts.js b/devops/scripts/benchmarks/html/scripts.js index 558021a13ab4a..eba01fb5e30d8 100644 --- a/devops/scripts/benchmarks/html/scripts.js +++ b/devops/scripts/benchmarks/html/scripts.js @@ -17,6 +17,12 @@ let annotationsOptions = new Map(); // Global options map for annotations let archivedDataLoaded = false; let loadedBenchmarkRuns = []; // Loaded results from the js/json files +// Global variables loaded from data.js: +// - benchmarkRuns: array of benchmark run data +// - benchmarkMetadata: metadata for benchmarks and groups +// - benchmarkTags: tag definitions +// - flamegraphData: available flamegraphs (optional, added dynamically) + // DOM Elements let runSelect, selectedRunsDiv, suiteFiltersContainer, tagFiltersContainer; @@ -313,8 +319,12 @@ function drawCharts(filteredTimeseriesData, filteredBarChartsData, filteredLayer const containerId = `timeseries-${index}`; const container = createChartContainer(data, containerId, 'benchmark'); document.querySelector('.timeseries .charts').appendChild(container); - pendingCharts.set(containerId, { data, type: 'time' }); - chartObserver.observe(container); + + // Only set up chart observers if not in flamegraph mode + if (!isFlameGraphEnabled()) { + pendingCharts.set(containerId, { data, type: 'time' }); + chartObserver.observe(container); + } }); // Create layer comparison charts @@ -322,8 +332,12 @@ function drawCharts(filteredTimeseriesData, filteredBarChartsData, filteredLayer const containerId = `layer-comparison-${index}`; const container = createChartContainer(data, containerId, 'group'); document.querySelector('.layer-comparisons .charts').appendChild(container); - pendingCharts.set(containerId, { data, type: 'time' }); - chartObserver.observe(container); + + // Only set up chart observers if not in flamegraph mode + if (!isFlameGraphEnabled()) { + pendingCharts.set(containerId, { data, type: 'time' }); + chartObserver.observe(container); + } }); // Create bar charts @@ -331,8 +345,12 @@ function drawCharts(filteredTimeseriesData, filteredBarChartsData, filteredLayer const containerId = `barchart-${index}`; const container = createChartContainer(data, containerId, 'group'); document.querySelector('.bar-charts .charts').appendChild(container); - pendingCharts.set(containerId, { data, type: 'bar' }); - chartObserver.observe(container); + + // Only set up chart observers if not in flamegraph mode + if (!isFlameGraphEnabled()) { + pendingCharts.set(containerId, { data, type: 'bar' }); + chartObserver.observe(container); + } }); // Apply current filters @@ -413,15 +431,75 @@ function createChartContainer(data, canvasId, type) { const contentSection = document.createElement('div'); contentSection.className = 'chart-content'; - // Canvas for the chart - fixed position in content flow - const canvas = document.createElement('canvas'); - canvas.id = canvasId; - canvas.style.width = '100%'; - - // Set a default height - will be properly sized later in createChart - canvas.style.height = '400px'; - canvas.style.marginBottom = '10px'; - contentSection.appendChild(canvas); + // Check if flamegraph mode is enabled + if (isFlameGraphEnabled()) { + // Get all flamegraph data for this benchmark from selected runs + const flamegraphsToShow = getFlameGraphsForBenchmark(data.label, activeRuns); + + if (flamegraphsToShow.length > 0) { + // Create multiple iframes for each run that has flamegraph data + flamegraphsToShow.forEach((flamegraphInfo, index) => { + const iframe = document.createElement('iframe'); + iframe.src = flamegraphInfo.path; + + // Calculate dimensions that fit within the existing container constraints + // The container has max-width: 1100px with 24px padding on each side + const containerMaxWidth = 1100; + const containerPadding = 48; // 24px on each side + const availableWidth = containerMaxWidth - containerPadding; + + // Set dimensions to fit within container without scrollbars + iframe.style.width = '100%'; + iframe.style.maxWidth = `${availableWidth}px`; + iframe.style.height = '600px'; + iframe.style.border = '1px solid #ddd'; + iframe.style.borderRadius = '4px'; + iframe.style.display = 'block'; + iframe.style.margin = index === 0 ? '0 auto 10px auto' : '10px auto'; // Add spacing between multiple iframes + iframe.title = `${flamegraphInfo.runName} - ${data.label}`; + + // Add error handling for missing flamegraph files + iframe.onerror = function() { + const errorDiv = document.createElement('div'); + errorDiv.className = 'flamegraph-error'; + errorDiv.textContent = `No flamegraph available for ${flamegraphInfo.runName} - ${data.label}`; + contentSection.replaceChild(errorDiv, iframe); + }; + + contentSection.appendChild(iframe); + }); + + // Add resize handling to maintain proper sizing for all iframes + const updateIframeSizes = () => { + const containerMaxWidth = 1100; + const containerPadding = 48; + const availableWidth = containerMaxWidth - containerPadding; + + contentSection.querySelectorAll('iframe[src*="flamegraphs"]').forEach(iframe => { + iframe.style.maxWidth = `${availableWidth}px`; + }); + }; + + // Update size on window resize + window.addEventListener('resize', updateIframeSizes); + } else { + // Show message when no flamegraph is available + const noFlameGraphDiv = document.createElement('div'); + noFlameGraphDiv.className = 'flamegraph-unavailable'; + noFlameGraphDiv.textContent = `No flamegraph data available for ${data.label}`; + contentSection.appendChild(noFlameGraphDiv); + } + } else { + // Canvas for the chart - fixed position in content flow + const canvas = document.createElement('canvas'); + canvas.id = canvasId; + canvas.style.width = '100%'; + + // Set a default height - will be properly sized later in createChart + canvas.style.height = '400px'; + canvas.style.marginBottom = '10px'; + contentSection.appendChild(canvas); + } container.appendChild(contentSection); @@ -652,6 +730,12 @@ function updateURL() { url.searchParams.set('archived', 'true'); } + if (!isFlameGraphEnabled()) { + url.searchParams.delete('flamegraph'); + } else { + url.searchParams.set('flamegraph', 'true'); + } + history.replaceState(null, '', url); } @@ -934,6 +1018,13 @@ function setupSuiteFilters() { }); }); + // Debug logging for suite names + console.log('Available suites:', Array.from(suiteNames)); + console.log('Loaded benchmark runs:', loadedBenchmarkRuns.map(run => ({ + name: run.name, + suites: [...new Set(run.results.map(r => r.suite))] + }))); + suiteNames.forEach(suite => { const label = document.createElement('label'); const checkbox = document.createElement('input'); @@ -968,11 +1059,107 @@ function isArchivedDataEnabled() { return archivedDataToggle.checked; } +function isFlameGraphEnabled() { + const flameGraphToggle = document.getElementById('show-flamegraph'); + return flameGraphToggle.checked; +} + +function validateFlameGraphData() { + return window.flamegraphData?.runs !== undefined; +} + +function createFlameGraphPath(benchmarkLabel, runName, timestamp) { + const benchmarkDirName = benchmarkLabel; + const timestampPrefix = timestamp + '_'; + return `results/flamegraphs/${encodeURIComponent(benchmarkDirName)}/${timestampPrefix}${runName}.svg`; +} + +function getRunsWithFlameGraph(benchmarkLabel, activeRuns) { + // Inline validation for better performance + if (!window.flamegraphData?.runs) { + return []; + } + + const runsWithFlameGraph = []; + activeRuns.forEach(runName => { + if (flamegraphData.runs[runName] && + flamegraphData.runs[runName].benchmarks && + flamegraphData.runs[runName].benchmarks.includes(benchmarkLabel)) { + runsWithFlameGraph.push({ + name: runName, + timestamp: flamegraphData.runs[runName].timestamp + }); + } + }); + + return runsWithFlameGraph; +} + +// Removed: getFlameGraphPath() - functionality consolidated into getFlameGraphsForBenchmark() + +function getFlameGraphsForBenchmark(benchmarkLabel, activeRuns) { + const runsWithFlameGraph = getRunsWithFlameGraph(benchmarkLabel, activeRuns); + const flamegraphsToShow = []; + + // For each run that has flamegraph data, create the path + runsWithFlameGraph.forEach(runInfo => { + const flamegraphPath = createFlameGraphPath(benchmarkLabel, runInfo.name, runInfo.timestamp); + + flamegraphsToShow.push({ + path: flamegraphPath, + runName: runInfo.name, + timestamp: runInfo.timestamp + }); + }); + + // Sort by the order of activeRuns to maintain consistent display order + const runOrder = Array.from(activeRuns); + flamegraphsToShow.sort((a, b) => { + const indexA = runOrder.indexOf(a.runName); + const indexB = runOrder.indexOf(b.runName); + return indexA - indexB; + }); + + return flamegraphsToShow; +} + +// Removed: getFlameGraphInfo() - unused function, functionality covered by getFlameGraphsForBenchmark() + +function updateFlameGraphTooltip() { + const flameGraphToggle = document.getElementById('show-flamegraph'); + const label = document.querySelector('label[for="show-flamegraph"]'); + + if (!flameGraphToggle || !label) return; + + // Check if we have flamegraph data + if (validateFlameGraphData()) { + const runsWithFlameGraphs = Object.keys(flamegraphData.runs).filter( + runName => flamegraphData.runs[runName].benchmarks && + flamegraphData.runs[runName].benchmarks.length > 0 + ); + + if (runsWithFlameGraphs.length > 0) { + label.title = `Show flamegraph SVG files instead of benchmark charts. Available for runs: ${runsWithFlameGraphs.join(', ')}`; + flameGraphToggle.disabled = false; + label.style.color = ''; + } else { + label.title = 'No flamegraph data available - run benchmarks with --flamegraph option to enable'; + flameGraphToggle.disabled = true; + label.style.color = '#999'; + } + } else { + label.title = 'No flamegraph data available - run benchmarks with --flamegraph option to enable'; + flameGraphToggle.disabled = true; + label.style.color = '#999'; + } +} + function setupToggles() { const notesToggle = document.getElementById('show-notes'); const unstableToggle = document.getElementById('show-unstable'); const customRangeToggle = document.getElementById('custom-range'); const archivedDataToggle = document.getElementById('show-archived-data'); + const flameGraphToggle = document.getElementById('show-flamegraph'); notesToggle.addEventListener('change', function () { // Update all note elements visibility @@ -995,6 +1182,17 @@ function setupToggles() { updateCharts(); }); + // Add event listener for flamegraph toggle + if (flameGraphToggle) { + flameGraphToggle.addEventListener('change', function() { + updateCharts(); + updateURL(); + }); + + // Update flamegraph toggle tooltip with run information + updateFlameGraphTooltip(); + } + // Add event listener for archived data toggle if (archivedDataToggle) { archivedDataToggle.addEventListener('change', function() { @@ -1014,6 +1212,7 @@ function setupToggles() { const notesParam = getQueryParam('notes'); const unstableParam = getQueryParam('unstable'); const archivedParam = getQueryParam('archived'); + const flamegraphParam = getQueryParam('flamegraph'); if (notesParam !== null) { let showNotes = notesParam === 'true'; @@ -1030,6 +1229,10 @@ function setupToggles() { customRangeToggle.checked = customRangesParam === 'true'; } + if (flameGraphToggle && flamegraphParam !== null) { + flameGraphToggle.checked = flamegraphParam === 'true'; + } + if (archivedDataToggle && archivedParam !== null) { archivedDataToggle.checked = archivedParam === 'true'; @@ -1112,12 +1315,68 @@ function toggleAllTags(select) { } function initializeCharts() { + console.log('initializeCharts() started'); + // Process raw data + console.log('Processing timeseries data...'); timeseriesData = processTimeseriesData(); + console.log('Timeseries data processed:', timeseriesData.length, 'items'); + + console.log('Processing bar charts data...'); barChartsData = processBarChartsData(); + console.log('Bar charts data processed:', barChartsData.length, 'items'); + + console.log('Processing layer comparisons data...'); layerComparisonsData = processLayerComparisonsData(); + console.log('Layer comparisons data processed:', layerComparisonsData.length, 'items'); + allRunNames = [...new Set(loadedBenchmarkRuns.map(run => run.name))]; latestRunsLookup = createLatestRunsLookup(); + console.log('Run names and lookup created. Runs:', allRunNames); + + // Check if we have actual benchmark results vs flamegraph-only results + const hasActualBenchmarks = loadedBenchmarkRuns.some(run => + run.results && run.results.some(result => result.suite !== 'flamegraph') + ); + + const hasFlameGraphResults = loadedBenchmarkRuns.some(run => + run.results && run.results.some(result => result.suite === 'flamegraph') + ) || (validateFlameGraphData() && Object.keys(flamegraphData.runs).length > 0); + + console.log('Benchmark analysis:', { + hasActualBenchmarks, + hasFlameGraphResults, + loadedBenchmarkRuns: loadedBenchmarkRuns.length + }); + + // If we only have flamegraph results (no actual benchmark data), create synthetic data + if (!hasActualBenchmarks && hasFlameGraphResults) { + console.log('Detected flamegraph-only mode - creating synthetic data for flamegraphs'); + + // Check if we have flamegraph data available + const hasFlamegraphData = validateFlameGraphData() && + Object.keys(flamegraphData.runs).length > 0 && + Object.values(flamegraphData.runs).some(run => run.benchmarks && run.benchmarks.length > 0); + + if (hasFlamegraphData) { + console.log('Creating synthetic benchmark data for flamegraph display'); + createFlameGraphOnlyData(); + + // Auto-enable flamegraph mode for user convenience + const flameGraphToggle = document.getElementById('show-flamegraph'); + if (flameGraphToggle && !flameGraphToggle.checked) { + flameGraphToggle.checked = true; + console.log('Auto-enabled flamegraph view for flamegraph-only data'); + } + } else { + console.log('No flamegraph data available - showing message'); + displayNoDataMessage(); + } + } else if (!hasActualBenchmarks && !hasFlameGraphResults) { + // No runs and no results - something went wrong + console.log('No benchmark data found at all'); + displayNoDataMessage(); + } // Create global options map for annotations annotationsOptions = createAnnotationsOptions(); @@ -1414,3 +1673,154 @@ function createPlatformDetailsHTML(platform) { function initializePlatformTab() { displaySelectedRunsPlatformInfo(); } + +// Function to create chart data for flamegraph-only mode +function createFlameGraphOnlyData() { + // Check if we have flamegraphData from data.js + if (validateFlameGraphData()) { + // Collect all unique benchmarks from all runs that have flamegraphs + const allBenchmarks = new Set(); + const availableRuns = Object.keys(flamegraphData.runs); + + availableRuns.forEach(runName => { + if (flamegraphData.runs[runName].benchmarks) { + flamegraphData.runs[runName].benchmarks.forEach(benchmark => { + allBenchmarks.add(benchmark); + }); + } + }); + + if (allBenchmarks.size > 0) { + console.log(`Using flamegraphData from data.js for runs: ${availableRuns.join(', ')}`); + console.log(`Available benchmarks with flamegraphs: ${Array.from(allBenchmarks).join(', ')}`); + createSyntheticFlameGraphData(Array.from(allBenchmarks)); + return; // Success - we have flamegraph data + } + } + + // No flamegraph data available - benchmarks were run without --flamegraph option + console.log('No flamegraph data found - benchmarks were likely run without --flamegraph option'); + + // Disable the flamegraph checkbox since no flamegraphs are available + const flameGraphToggle = document.getElementById('show-flamegraph'); + if (flameGraphToggle) { + flameGraphToggle.disabled = true; + flameGraphToggle.checked = false; + + // Add a visual indicator that flamegraphs are not available + const label = document.querySelector('label[for="show-flamegraph"]'); + if (label) { + label.style.color = '#999'; + label.title = 'No flamegraph data available - run benchmarks with --flamegraph option to enable'; + } + + console.log('Disabled flamegraph toggle - no flamegraph data available'); + } + + // Clear any flamegraph-only mode detection and proceed with normal benchmark display + // This handles the case where we're in flamegraph-only mode but have no actual flamegraph data +} + +function displayNoFlameGraphsMessage() { + // Clear existing data arrays + timeseriesData = []; + barChartsData = []; + layerComparisonsData = []; + + // Add a special suite for the message + suiteNames.add('Information'); + + // Create a special entry to show a helpful message + const messageData = { + label: 'No FlameGraphs Available', + display_label: 'No FlameGraphs Available', + suite: 'Information', + unit: 'message', + lower_is_better: false, + range_min: null, + range_max: null, + runs: {} + }; + + timeseriesData.push(messageData); + console.log('Added informational message about missing flamegraphs'); +} + +function displayNoDataMessage() { + // Clear existing data arrays + timeseriesData = []; + barChartsData = []; + layerComparisonsData = []; + + // Add a special suite for the message + suiteNames.add('Information'); + + // Create a special entry to show a helpful message + const messageData = { + label: 'No Data Available', + display_label: 'No Benchmark Data Available', + suite: 'Information', + unit: 'message', + lower_is_better: false, + range_min: null, + range_max: null, + runs: {} + }; + + timeseriesData.push(messageData); + console.log('Added informational message about missing benchmark data'); +} + +function createSyntheticFlameGraphData(flamegraphLabels) { + // Clear existing data arrays since we're in flamegraph-only mode + timeseriesData = []; + barChartsData = []; + layerComparisonsData = []; + + // Create synthetic benchmark results for each flamegraph + flamegraphLabels.forEach(label => { + // Try to determine suite from metadata, default to "Flamegraphs" + const metadata = metadataForLabel(label, 'benchmark'); + let suite = 'Flamegraphs'; + + // Try to match with existing metadata to get proper suite name + if (metadata) { + // Most flamegraphs are likely from Compute Benchmarks + suite = 'Compute Benchmarks'; + } else { + // For common benchmark patterns, assume Compute Benchmarks + const computeBenchmarkPatterns = [ + 'SubmitKernel', 'SubmitGraph', 'FinalizeGraph', 'SinKernelGraph', + 'AllocateBuffer', 'CopyBuffer', 'CopyImage', 'CreateBuffer', + 'CreateContext', 'CreateImage', 'CreateKernel', 'CreateProgram', + 'CreateQueue', 'ExecuteKernel', 'MapBuffer', 'MapImage', + 'ReadBuffer', 'ReadImage', 'WriteBuffer', 'WriteImage' + ]; + + if (computeBenchmarkPatterns.some(pattern => label.includes(pattern))) { + suite = 'Compute Benchmarks'; + } + } + + // Add to suite names + suiteNames.add(suite); + + // Create a synthetic timeseries entry for this flamegraph + const syntheticData = { + label: label, + display_label: metadata?.display_name || label, + suite: suite, + unit: 'flamegraph', + lower_is_better: false, + range_min: null, + range_max: null, + runs: {} + }; + + // Add this to timeseriesData so it shows up in the charts + timeseriesData.push(syntheticData); + }); + + console.log(`Created synthetic data for ${flamegraphLabels.length} flamegraphs with suites:`, Array.from(suiteNames)); +} + diff --git a/devops/scripts/benchmarks/html/styles.css b/devops/scripts/benchmarks/html/styles.css index d0f61a318fbf6..cb513a95581fa 100644 --- a/devops/scripts/benchmarks/html/styles.css +++ b/devops/scripts/benchmarks/html/styles.css @@ -439,4 +439,48 @@ details[open] summary::after { margin-left: 0; margin-top: 4px; } -} \ No newline at end of file +} +/* FlameGraph specific styles */ +.flamegraph-error, +.flamegraph-unavailable { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 20px; + text-align: center; + color: #6c757d; + font-style: italic; + margin: 10px 0; +} + +.flamegraph-error { + background-color: #fff3cd; + border-color: #ffeaa7; + color: #856404; +} + +/* Updated selector to match new title format (RunName - BenchmarkName) */ +iframe[src*="flamegraphs"] { + transition: all 0.3s ease; + box-sizing: border-box; + max-width: 100%; + width: 100%; + border: none; + overflow: hidden; +} + +/* Ensure flamegraph containers have proper spacing and fit within container */ +.chart-container iframe { + margin-bottom: 10px; +} + +/* Handle multiple flamegraphs displayed vertically */ +.chart-content iframe[src*="flamegraphs"]:not(:last-child) { + margin-bottom: 15px; + border-bottom: 2px solid #e9ecef; +} + +/* Add subtle visual separation between multiple flamegraphs */ +.chart-content iframe[src*="flamegraphs"]:not(:first-child) { + margin-top: 15px; +} diff --git a/devops/scripts/benchmarks/main.py b/devops/scripts/benchmarks/main.py index 2e92c9283063c..76ef140740b9b 100755 --- a/devops/scripts/benchmarks/main.py +++ b/devops/scripts/benchmarks/main.py @@ -18,6 +18,7 @@ from benches.umf import * from benches.test import TestSuite from benches.benchdnn import OneDnnBench +from benches.base import TracingType from options import Compare, options from output_markdown import generate_markdown from output_html import generate_html @@ -28,6 +29,7 @@ from utils.detect_versions import DetectVersion from utils.logger import log from utils.unitrace import get_unitrace +from utils.flamegraph import get_flamegraph from presets import enabled_suites, presets # Update this if you are changing the layout of the results files @@ -41,11 +43,14 @@ def run_iterations( results: dict[str, list[Result]], failures: dict[str, str], run_unitrace: bool = False, + run_flamegraph: bool = False, ): for iter in range(iters): log.info(f"running {benchmark.name()}, iteration {iter}... ") try: - bench_results = benchmark.run(env_vars, run_unitrace=run_unitrace) + bench_results = benchmark.run( + env_vars, run_unitrace=run_unitrace, run_flamegraph=run_flamegraph + ) if bench_results is None: if options.exit_on_failure: raise RuntimeError(f"Benchmark produced no results!") @@ -179,6 +184,11 @@ def main(directory, additional_env_vars, compare_names, filter): "Unitrace requires a save name to be specified via --save option." ) + if options.flamegraph and options.save_name is None: + raise ValueError( + "FlameGraph requires a save name to be specified via --save option." + ) + if options.build_compute_runtime: log.info(f"Setting up Compute Runtime {options.compute_runtime_tag}") cr = get_compute_runtime() @@ -261,8 +271,19 @@ def main(directory, additional_env_vars, compare_names, filter): merged_env_vars = {**additional_env_vars} intermediate_results: dict[str, list[Result]] = {} processed: list[Result] = [] - # regular run of the benchmark (if no unitrace or unitrace inclusive) - if args.unitrace != "exclusive": + + # Determine if we should run regular benchmarks + # Run regular benchmarks if: + # - No tracing options specified, OR + # - Any tracing option is set to "inclusive" + should_run_regular = ( + not options.unitrace + and not options.flamegraph # No tracing options + or args.unitrace == "inclusive" # Unitrace inclusive + or args.flamegraph == "inclusive" # Flamegraph inclusive + ) + + if should_run_regular: for _ in range(options.iterations_stddev): run_iterations( benchmark, @@ -271,14 +292,16 @@ def main(directory, additional_env_vars, compare_names, filter): intermediate_results, failures, run_unitrace=False, + run_flamegraph=False, ) valid, processed = process_results( intermediate_results, benchmark.stddev_threshold() ) if valid: break + # single unitrace run independent of benchmark iterations (if unitrace enabled) - if options.unitrace and benchmark.traceable(): + if options.unitrace and benchmark.traceable(TracingType.UNITRACE): run_iterations( benchmark, merged_env_vars, @@ -286,7 +309,20 @@ def main(directory, additional_env_vars, compare_names, filter): intermediate_results, failures, run_unitrace=True, + run_flamegraph=False, + ) + # single flamegraph run independent of benchmark iterations (if flamegraph enabled) + if options.flamegraph and benchmark.traceable(TracingType.FLAMEGRAPH): + run_iterations( + benchmark, + merged_env_vars, + 1, + intermediate_results, + failures, + run_unitrace=False, + run_flamegraph=True, ) + results += processed except Exception as e: if options.exit_on_failure: @@ -363,6 +399,17 @@ def main(directory, additional_env_vars, compare_names, filter): os.path.join(os.path.dirname(__file__), "html") ) log.info(f"Generating HTML with benchmark results in {html_path}...") + + # Reload history after saving to include the current results in HTML output + if not options.dry_run: + log.debug( + "Reloading history for HTML generation to include current results..." + ) + history.load() + log.debug( + f"Reloaded {len(history.runs)} benchmark runs for HTML generation." + ) + generate_html(history, compare_names, html_path, metadata) log.info(f"HTML with benchmark results has been generated") @@ -559,6 +606,14 @@ def validate_and_parse_env_args(env_args): help="Unitrace tracing for single iteration of benchmarks. Inclusive tracing is done along regular benchmarks.", choices=["inclusive", "exclusive"], ) + parser.add_argument( + "--flamegraph", + nargs="?", + const="exclusive", + default=None, + help="FlameGraph generation for single iteration of benchmarks. Inclusive generation is done along regular benchmarks.", + choices=["inclusive", "exclusive"], + ) # Options intended for CI: parser.add_argument( @@ -659,7 +714,8 @@ def validate_and_parse_env_args(env_args): options.compare = Compare(args.compare_type) options.compare_max = args.compare_max options.output_markdown = args.output_markdown - options.output_html = args.output_html + if args.output_html is not None: + options.output_html = args.output_html options.dry_run = args.dry_run options.umf = args.umf options.iterations_stddev = args.iterations_stddev @@ -671,6 +727,7 @@ def validate_and_parse_env_args(env_args): options.results_directory_override = args.results_dir options.build_jobs = args.build_jobs options.hip_arch = args.hip_arch + options.flamegraph = args.flamegraph is not None # Initialize logger with command line arguments log.initialize(args.verbose, args.log_level) diff --git a/devops/scripts/benchmarks/options.py b/devops/scripts/benchmarks/options.py index 371c9b7a70403..322a68d9e211b 100644 --- a/devops/scripts/benchmarks/options.py +++ b/devops/scripts/benchmarks/options.py @@ -43,6 +43,7 @@ class DetectVersionsOptions: @dataclass class Options: + TIMESTAMP_FORMAT: str = "%Y%m%d_%H%M%S" workdir: str = None sycl: str = None ur: str = None @@ -71,6 +72,8 @@ class Options: preset: str = "Full" build_jobs: int = len(os.sched_getaffinity(0)) # Cores available for the process. exit_on_failure: bool = False + save_name: str = None + flamegraph: bool = False unitrace: bool = False TIMESTAMP_FORMAT = "%Y%m%d_%H%M%S" # Format for timestamps in filenames and logs, including Unitrace traces. diff --git a/devops/scripts/benchmarks/output_html.py b/devops/scripts/benchmarks/output_html.py index 3b507f639ac75..e9c0e91f012f9 100644 --- a/devops/scripts/benchmarks/output_html.py +++ b/devops/scripts/benchmarks/output_html.py @@ -5,6 +5,7 @@ import json import os +import glob from options import options from utils.result import BenchmarkMetadata, BenchmarkOutput @@ -24,24 +25,67 @@ def _write_output_to_file( if options.output_html == "local": data_path = os.path.join(html_path, f"{filename}.js") + + # Check if the file exists and has flamegraph data that we need to preserve + existing_flamegraph_data = None + if os.path.exists(data_path): + try: + with open(data_path, "r") as f: + existing_content = f.read() + # Extract existing flamegraphData if present + if "flamegraphData = {" in existing_content: + start = existing_content.find("flamegraphData = {") + if start != -1: + # Find the end of the flamegraphData object + brace_count = 0 + found_start = False + end = start + for i, char in enumerate(existing_content[start:], start): + if char == "{": + brace_count += 1 + found_start = True + elif char == "}" and found_start: + brace_count -= 1 + if brace_count == 0: + end = i + 1 + break + if found_start and end > start: + # Extract the complete flamegraphData section including the semicolon + next_semicolon = existing_content.find(";", end) + if next_semicolon != -1: + existing_flamegraph_data = existing_content[ + start : next_semicolon + 1 + ] + log.debug( + "Preserved existing flamegraph data for HTML output" + ) + except Exception as e: + log.debug(f"Could not parse existing flamegraph data: {e}") + with open(data_path, "w") as f: # For local format, we need to write JavaScript variable assignments f.write("benchmarkRuns = ") json.dump(json.loads(output.to_json())["runs"], f, indent=2) f.write(";\n\n") - f.write(f"benchmarkMetadata = ") - json.dump(json.loads(output.to_json())["metadata"], f, indent=2) + f.write("defaultCompareNames = ") + json.dump(output.default_compare_names, f, indent=2) f.write(";\n\n") - f.write(f"benchmarkTags = ") - json.dump(json.loads(output.to_json())["tags"], f, indent=2) + f.write("benchmarkMetadata = ") + json.dump(json.loads(output.to_json())["metadata"], f, indent=2) f.write(";\n\n") - f.write(f"defaultCompareNames = ") - json.dump(output.default_compare_names, f, indent=2) + f.write("benchmarkTags = ") + json.dump(json.loads(output.to_json())["tags"], f, indent=2) f.write(";\n") + # Preserve and append existing flamegraph data if any + if existing_flamegraph_data: + f.write("\n") + f.write(existing_flamegraph_data) + f.write("\n") + if not archive: log.info(f"See {html_path}/index.html for the results.") else: diff --git a/devops/scripts/benchmarks/utils/flamegraph.py b/devops/scripts/benchmarks/utils/flamegraph.py new file mode 100644 index 0000000000000..972955b0c42a2 --- /dev/null +++ b/devops/scripts/benchmarks/utils/flamegraph.py @@ -0,0 +1,436 @@ +# Copyright (C) 2025 Intel Corporation +# Part of the Unified-Runtime Project, under the Apache License v2.0 with LLVM Exceptions. +# See LICENSE.TXT +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +import os +import shutil +import subprocess +import signal +import time + +from options import options +from utils.utils import run, git_clone +from utils.logger import log + +from datetime import datetime, timezone + + +class FlameGraph: + """FlameGraph wrapper for managing FlameGraph tool execution and results.""" + + # FlameGraph SVG width to fit within web interface container + # Optimized width to avoid horizontal scrollbars within 1100px container + FLAMEGRAPH_WIDTH = 1000 + + def __init__(self): + self.timestamp = ( + datetime.now(tz=timezone.utc).strftime(options.TIMESTAMP_FORMAT) + if options.timestamp_override is None + else options.timestamp_override + ) + + log.info("Downloading FlameGraph...") + repo_dir = git_clone( + options.workdir, + "flamegraph-repo", + "https://github.com/brendangregg/FlameGraph.git", + "master", + ) + + # FlameGraph doesn't need building, just verify scripts exist and are executable + flamegraph_scripts = [ + "flamegraph.pl", + "stackcollapse-perf.pl", + "stackcollapse.pl", + ] + + for script in flamegraph_scripts: + script_path = os.path.join(repo_dir, script) + # First check if script exists + if not os.path.exists(script_path): + raise FileNotFoundError(f"FlameGraph script not found: {script_path}") + # Then check if it's executable + if not os.access(script_path, os.X_OK): + raise RuntimeError(f"FlameGraph script not executable: {script_path}") + log.debug(f"Verified {script} exists and is executable") + + # Store repo_dir for later use when generating flamegraphs + self.repo_dir = repo_dir + + log.info("FlameGraph tools ready.") + + if options.results_directory_override == None: + self.flamegraphs_dir = os.path.join( + options.workdir, "results", "flamegraphs" + ) + else: + self.flamegraphs_dir = os.path.join( + options.results_directory_override, "flamegraphs" + ) + + def _prune_flamegraph_dirs(self, res_dir: str, FILECNT: int = 10): + """Keep only the last FILECNT files in the flamegraphs directory.""" + files = os.listdir(res_dir) + files.sort() # Lexicographical sort matches timestamp order + if len(files) > 2 * FILECNT: + for f in files[: len(files) - 2 * FILECNT]: + full_path = os.path.join(res_dir, f) + if os.path.isdir(full_path): + shutil.rmtree(full_path) + else: + os.remove(full_path) + log.debug(f"Removing old flamegraph file: {full_path}") + + def cleanup(self, bench_cwd: str, perf_data_file: str): + """ + Remove incomplete output files in case of failure. + """ + flamegraph_dir = os.path.dirname(perf_data_file) + flamegraph_base = os.path.basename(perf_data_file) + for f in os.listdir(flamegraph_dir): + if f.startswith(flamegraph_base + "."): + os.remove(os.path.join(flamegraph_dir, f)) + log.debug(f"Cleanup: Removed {f} from {flamegraph_dir}") + + def setup( + self, bench_name: str, command: list[str], extra_perf_opt: list[str] = None + ): + """ + Prepare perf data file name and full command for the benchmark run. + Returns a tuple of (perf_data_file, perf_command). + """ + # Check if perf is available + if not shutil.which("perf"): + raise FileNotFoundError( + "perf command not found. Please install linux-tools or perf package." + ) + + os.makedirs(self.flamegraphs_dir, exist_ok=True) + bench_dir = os.path.join(f"{self.flamegraphs_dir}", f"{bench_name}") + + os.makedirs(bench_dir, exist_ok=True) + + perf_data_file = os.path.join( + bench_dir, f"{self.timestamp}_{options.save_name}.perf.data" + ) + + if extra_perf_opt is None: + extra_perf_opt = [] + + # Default perf record options for flamegraph generation + perf_command = ( + [ + "perf", + "record", + "-g", # Enable call-graph recording + "-F", + "99", # Sample frequency + "--call-graph", + "dwarf", # Use DWARF unwinding for better stack traces + "-o", + perf_data_file, + ] + + extra_perf_opt + + ["--"] + + command + ) + log.debug(f"Perf cmd: {' '.join(perf_command)}") + + return perf_data_file, perf_command + + def handle_output(self, bench_name: str, perf_data_file: str): + """ + Generate SVG flamegraph from perf data file. + Returns the path to the generated SVG file. + """ + if not os.path.exists(perf_data_file) or os.path.getsize(perf_data_file) == 0: + raise FileNotFoundError( + f"Perf data file not found or empty: {perf_data_file}" + ) + + # Generate output SVG filename following same pattern as perf data + svg_file = perf_data_file.replace(".perf.data", ".svg") + folded_file = perf_data_file.replace(".perf.data", ".folded") + + try: + # Step 1: Convert perf script to folded format + log.debug(f"Converting perf data to folded format: {folded_file}") + with open(folded_file, "w") as f_folded: + # Run perf script to get the stack traces + perf_script_proc = subprocess.Popen( + ["perf", "script", "-i", perf_data_file], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + + # Pipe through stackcollapse-perf.pl + stackcollapse_perf_path = os.path.join( + self.repo_dir, "stackcollapse-perf.pl" + ) + stackcollapse_proc = subprocess.Popen( + [stackcollapse_perf_path], + stdin=perf_script_proc.stdout, + stdout=f_folded, + stderr=subprocess.DEVNULL, + text=True, + ) + + perf_script_proc.stdout.close() + stackcollapse_proc.wait() + perf_script_proc.wait() + + # Step 2: Generate flamegraph SVG + log.debug(f"Generating flamegraph SVG: {svg_file}") + flamegraph_pl_path = os.path.join(self.repo_dir, "flamegraph.pl") + with open(folded_file, "r") as f_folded, open(svg_file, "w") as f_svg: + flamegraph_proc = subprocess.Popen( + [ + flamegraph_pl_path, + "--title", + f"{options.save_name} - {bench_name}", + "--width", + str( + self.FLAMEGRAPH_WIDTH + ), # Fit within container without scrollbars + ], + stdin=f_folded, + stdout=f_svg, + stderr=subprocess.DEVNULL, + text=True, + ) + flamegraph_proc.wait() + + # Clean up intermediate files + if os.path.exists(folded_file): + os.remove(folded_file) + + if not os.path.exists(svg_file) or os.path.getsize(svg_file) == 0: + raise RuntimeError(f"Failed to generate flamegraph SVG: {svg_file}") + + log.debug(f"Generated flamegraph: {svg_file}") + + # Create symlink immediately after SVG generation + self._create_immediate_symlink(svg_file) + + # Prune old flamegraph directories + self._prune_flamegraph_dirs(os.path.dirname(perf_data_file)) + + return svg_file + + except Exception as e: + # Clean up on failure + for temp_file in [folded_file, svg_file]: + if os.path.exists(temp_file): + os.remove(temp_file) + raise RuntimeError(f"Failed to generate flamegraph for {bench_name}: {e}") + + def _create_immediate_symlink(self, svg_file: str): + """ + Create a symbolic link for the SVG file immediately after generation. + This ensures the web interface can access the file right away. + """ + try: + # Check if workdir is available + if not options.workdir: + log.error("No workdir available for immediate symlink creation") + return + + # Use the default HTML path relative to the script location + script_dir = os.path.dirname(os.path.dirname(__file__)) # Go up from utils/ + html_path = os.path.join(script_dir, "html") + + if not os.path.exists(html_path): + log.error(f"HTML directory not found: {html_path}") + return + + # Calculate the relative path of the SVG file from the flamegraphs directory + if not svg_file.startswith(self.flamegraphs_dir): + log.error(f"SVG file not in expected flamegraphs directory: {svg_file}") + return + + rel_path = os.path.relpath(svg_file, self.flamegraphs_dir) + target_dir = os.path.join(html_path, "results", "flamegraphs") + target_file = os.path.join(target_dir, rel_path) + + # Create target directory structure + os.makedirs(os.path.dirname(target_file), exist_ok=True) + + # Remove existing symlink if it exists + if os.path.islink(target_file): + os.unlink(target_file) + elif os.path.exists(target_file): + os.remove(target_file) + + # Create the symlink + os.symlink(svg_file, target_file) + log.debug(f"Created immediate symlink: {target_file} -> {svg_file}") + + # Update the flamegraph manifest for the web interface + self._update_flamegraph_manifest(html_path, rel_path, options.save_name) + + except Exception as e: + log.debug(f"Failed to create immediate symlink for {svg_file}: {e}") + + def _update_flamegraph_manifest( + self, html_path: str, svg_rel_path: str, run_name: str + ): + """ + Update the data.js file with flamegraph information by dynamically adding the flamegraphData variable. + This works with a clean data.js from the repo and adds the necessary structure during execution. + """ + try: + import re + from datetime import datetime + + # Extract benchmark name from the relative path + # Format: benchmark_name/timestamp_runname.svg + path_parts = svg_rel_path.split("/") + if len(path_parts) >= 1: + benchmark_name = path_parts[0] + + data_js_file = os.path.join(html_path, "data.js") + + # Read the current data.js file + if not os.path.exists(data_js_file): + log.error( + f"data.js not found at {data_js_file}, cannot update flamegraph manifest" + ) + return + + with open(data_js_file, "r") as f: + content = f.read() + + # Check if flamegraphData already exists + if "flamegraphData" not in content: + # Add flamegraphData object at the end of the file + flamegraph_data = f""" + +flamegraphData = {{ + runs: {{}}, + last_updated: '{datetime.now().isoformat()}' +}};""" + content = content.rstrip() + flamegraph_data + log.debug("Added flamegraphData object to data.js") + + # Parse and update the flamegraphData runs structure + flamegraph_start = content.find("flamegraphData = {") + if flamegraph_start != -1: + # Find the runs object within flamegraphData + runs_start = content.find("runs: {", flamegraph_start) + if runs_start != -1: + # Find the matching closing brace for the runs object + brace_count = 0 + runs_content_start = runs_start + 7 # After "runs: {" + runs_content_end = runs_content_start + + for i, char in enumerate( + content[runs_content_start:], runs_content_start + ): + if char == "{": + brace_count += 1 + elif char == "}": + if brace_count == 0: + runs_content_end = i + break + brace_count -= 1 + + existing_runs_str = content[ + runs_content_start:runs_content_end + ].strip() + existing_runs = {} + + # Parse existing runs if any + if existing_runs_str: + # Simple parsing of run entries like: "RunName": { benchmarks: [...], timestamp: "..." } + run_matches = re.findall( + r'"([^"]+)":\s*\{[^}]*benchmarks:\s*\[([^\]]*)\][^}]*timestamp:\s*"([^"]*)"[^}]*\}', + existing_runs_str, + ) + + for run_match in run_matches: + existing_run_name = run_match[0] + existing_benchmarks_str = run_match[1] + existing_timestamp = run_match[2] + + # Parse benchmarks array + existing_benchmarks = [] + if existing_benchmarks_str.strip(): + benchmark_matches = re.findall( + r'"([^"]*)"', existing_benchmarks_str + ) + existing_benchmarks = benchmark_matches + + existing_runs[existing_run_name] = { + "benchmarks": existing_benchmarks, + "timestamp": existing_timestamp, + } + + # Add or update this run's benchmark + if run_name not in existing_runs: + existing_runs[run_name] = { + "benchmarks": [], + "timestamp": self.timestamp, + } + else: + # Update timestamp to latest for this run (in case of multiple benchmarks in same run) + if self.timestamp > existing_runs[run_name]["timestamp"]: + existing_runs[run_name]["timestamp"] = self.timestamp + + # Add benchmark if not already present + if benchmark_name not in existing_runs[run_name]["benchmarks"]: + existing_runs[run_name]["benchmarks"].append(benchmark_name) + existing_runs[run_name]["benchmarks"].sort() # Keep sorted + log.debug( + f"Added {benchmark_name} to flamegraphData for run {run_name}" + ) + + # Create the new runs object string + runs_entries = [] + for rn, data in existing_runs.items(): + benchmarks_array = ( + "[" + + ", ".join(f'"{b}"' for b in data["benchmarks"]) + + "]" + ) + runs_entries.append( + f' "{rn}": {{\n benchmarks: {benchmarks_array},\n timestamp: "{data["timestamp"]}"\n }}' + ) + + runs_object = "{\n" + ",\n".join(runs_entries) + "\n }" + + # Replace the runs object by reconstructing the content + before_runs = content[: runs_start + 7] # Up to "runs: {" + after_runs_brace = content[ + runs_content_end: + ] # From the closing } onwards + content = ( + before_runs + runs_object[1:-1] + after_runs_brace + ) # Remove outer braces from runs_object + + # Update the last_updated timestamp + timestamp_pattern = r'(last_updated:\s*)["\'][^"\']*["\']' + content = re.sub( + timestamp_pattern, + rf'\g<1>"{datetime.now().isoformat()}"', + content, + ) + + # Write the updated content back to data.js + with open(data_js_file, "w") as f: + f.write(content) + + log.debug( + f"Updated data.js with flamegraph data for {benchmark_name} in run {run_name}" + ) + + except Exception as e: + log.debug(f"Failed to update data.js with flamegraph data: {e}") + + +# Singleton pattern to ensure only one instance of FlameGraph is created +def get_flamegraph() -> FlameGraph: + if not hasattr(get_flamegraph, "_instance"): + get_flamegraph._instance = FlameGraph() + return get_flamegraph._instance diff --git a/devops/scripts/benchmarks/utils/utils.py b/devops/scripts/benchmarks/utils/utils.py index fa355f2762673..650af53cf5145 100644 --- a/devops/scripts/benchmarks/utils/utils.py +++ b/devops/scripts/benchmarks/utils/utils.py @@ -77,8 +77,10 @@ def run( return result except subprocess.CalledProcessError as e: - log.error(e.stdout.decode()) - log.error(e.stderr.decode()) + if e.stdout and e.stdout.decode().strip(): + log.error(e.stdout.decode()) + if e.stderr and e.stderr.decode().strip(): + log.error(e.stderr.decode()) raise