From 17ed56546cdcc85402ce1f9ca38576dcb64c47ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Sun, 14 Dec 2025 20:39:53 +0000 Subject: [PATCH 1/4] Add sampling profiler visualisation Add an interactive JS visualisation to demonstrate how the sampling profiler works. A small demo program is shown executing line by line, demonstrating how stack frames are created and destroyed and how samples are taken periodically during the process. A new Sphinx extension is used to capture and inject the trace into the JS file during documentation build to make these traces reproducible (and avoid hard coding them in the source code). --- .../profiling-sampling-visualization.css | 591 ++++++++ .../profiling-sampling-visualization.js | 1264 +++++++++++++++++ Doc/conf.py | 1 + .../profiling-sampling-visualization.html | 3 + Doc/library/profiling.sampling.rst | 17 + Doc/tools/extensions/profiling_trace.py | 164 +++ 6 files changed, 2040 insertions(+) create mode 100644 Doc/_static/profiling-sampling-visualization.css create mode 100644 Doc/_static/profiling-sampling-visualization.js create mode 100644 Doc/library/profiling-sampling-visualization.html create mode 100644 Doc/tools/extensions/profiling_trace.py diff --git a/Doc/_static/profiling-sampling-visualization.css b/Doc/_static/profiling-sampling-visualization.css new file mode 100644 index 00000000000000..5259236359b19e --- /dev/null +++ b/Doc/_static/profiling-sampling-visualization.css @@ -0,0 +1,591 @@ +/** + * Sampling Profiler Visualization - Scoped CSS + */ + +.sampling-profiler-viz { + /* Match docs background colors */ + --bg-page: #ffffff; + --bg-panel: #ffffff; + --bg-subtle: #f8f8f8; + --bg-code: #f8f8f8; + + /* Match docs border style */ + --border-color: #e1e4e8; + --border-accent: #3776ab; + + /* Match docs text colors */ + --text-primary: #0d0d0d; + --text-secondary: #505050; + --text-muted: #6e6e6e; + --text-code: #333333; + + /* Accent colors - matching Tachyon logo */ + --color-python-blue: #306998; + --color-tachyon-gold: #d4a910; + --color-green: #388e3c; + --color-orange: #e65100; + --color-purple: #7b1fa2; + --color-red: #c62828; + --color-teal: #00897b; + --color-yellow: #d4a910; + --color-highlight: #fff9e6; + + --radius-lg: 8px; + --radius-md: 6px; + --radius-sm: 4px; + + /* Lighter shadows to match docs style */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + + --container-height: 520px; + --code-panel-width: 320px; + + /* Reset for isolation */ + font-family: var(--font-ui); + line-height: 1.5; + font-weight: 400; + color: var(--text-primary); + background-color: var(--bg-page); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Layout */ + position: relative; + width: 100%; + max-width: 920px; + height: var(--container-height); + display: grid; + grid-template-columns: var(--code-panel-width) 1fr; + margin: 24px auto; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + border: 1px solid var(--border-color); + background: var(--bg-panel); + /* Prevent any DOM changes inside from affecting page scroll */ + contain: strict; +} + +.sampling-profiler-viz * { + box-sizing: border-box; +} + +/* Code Panel - Left Column */ +.sampling-profiler-viz #code-panel { + background: var(--bg-panel); + border-right: 1px solid var(--border-color); + overflow-y: auto; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + display: flex; + flex-direction: column; +} + +.sampling-profiler-viz #code-panel .code-panel-title { + padding: 12px 16px; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--border-color); + background: var(--bg-code); + text-transform: uppercase; + letter-spacing: 1px; + flex-shrink: 0; +} + +.sampling-profiler-viz #code-panel .code-container { + margin: 0; + padding: 12px 0; + overflow-x: auto; + flex: 1; +} + +.sampling-profiler-viz #code-panel .line { + display: flex; + padding: 1px 0; + min-height: 20px; + transition: background-color 0.1s ease; +} + +.sampling-profiler-viz #code-panel .line-number { + color: var(--text-muted); + min-width: 40px; + text-align: right; + padding-right: 12px; + padding-left: 12px; + user-select: none; + flex-shrink: 0; + font-size: 11px; +} + +.sampling-profiler-viz #code-panel .line-content { + flex: 1; + color: var(--text-code); + padding-right: 12px; + white-space: pre; +} + +.sampling-profiler-viz #code-panel .line.highlighted { + background: var(--color-highlight); + border-left: 3px solid var(--color-yellow); +} + +.sampling-profiler-viz #code-panel .line.highlighted .line-number { + color: var(--color-yellow); + padding-left: 9px; +} + +.sampling-profiler-viz #code-panel .line.highlighted .line-content { + font-weight: 600; +} + +/* Python Syntax Highlighting */ +.sampling-profiler-viz #code-panel .keyword { + color: var(--color-red); + font-weight: 600; +} + +.sampling-profiler-viz #code-panel .function { + color: var(--color-purple); + font-weight: 600; +} + +.sampling-profiler-viz #code-panel .number { + color: var(--color-python-blue); +} + +.sampling-profiler-viz #code-panel .string { + color: #032f62; +} + +.sampling-profiler-viz #code-panel .comment { + color: #6a737d; + font-style: italic; +} + +.sampling-profiler-viz #code-panel .builtin { + color: var(--color-python-blue); +} + +/* Visualization Column - Right Side */ +.sampling-profiler-viz .viz-column { + display: flex; + flex-direction: column; + background: var(--bg-subtle); + overflow: hidden; +} + +/* Stack Section */ +.sampling-profiler-viz .stack-section { + padding: 12px 16px; + flex: 1; + min-height: 150px; + overflow-y: auto; +} + +.sampling-profiler-viz .stack-section-title { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 10px; +} + +.sampling-profiler-viz .stack-visualization { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 80px; +} + +/* Stack Frames - Vertical Layout */ +.sampling-profiler-viz .stack-frame { + position: relative; + width: 100%; + height: 32px; + cursor: pointer; + contain: layout style paint; + opacity: 0; + transform: translateY(-10px); +} + +.sampling-profiler-viz .stack-frame.visible { + opacity: 1; + transform: translateY(0); + transition: + opacity 0.3s ease, + transform 0.3s ease; +} + +.sampling-profiler-viz .stack-frame-bg { + position: absolute; + inset: 0; + border-radius: var(--radius-sm); + transition: opacity 0.15s; +} + +.sampling-profiler-viz .stack-frame[data-function="main"] .stack-frame-bg { + background: var(--color-python-blue); +} + +.sampling-profiler-viz .stack-frame[data-function="fibonacci"] .stack-frame-bg { + background: var(--color-tachyon-gold); +} + +.sampling-profiler-viz .stack-frame[data-function="add"] .stack-frame-bg { + background: var(--color-orange); +} + +.sampling-profiler-viz .stack-frame[data-function="multiply"] .stack-frame-bg { + background: var(--color-purple); +} + +.sampling-profiler-viz .stack-frame[data-function="calculate"] .stack-frame-bg { + background: var(--color-tachyon-gold); +} + +.sampling-profiler-viz .stack-frame-text { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + font: 500 11px var(--font-mono); + color: white; + pointer-events: none; +} + +.sampling-profiler-viz .stack-frame-flash { + position: absolute; + inset: 0; + background: white; + border-radius: var(--radius-sm); + opacity: 0; + pointer-events: none; +} + +.sampling-profiler-viz .stack-frame:hover .stack-frame-bg { + opacity: 0.85; +} + +/* Flying frames */ +.sampling-profiler-viz .stack-frame.flying { + pointer-events: none; + z-index: 1000; + position: fixed; + top: 0; + left: 0; + width: auto; + height: 32px; + opacity: 1; +} + +/* Flying stack clone */ +.stack-visualization.flying-clone { + transform-origin: center center; + will-change: transform, opacity; +} + +/* Sampling Panel */ +.sampling-profiler-viz .sampling-panel { + margin: 0 16px 12px 16px; + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + overflow: hidden; + display: flex; + flex-direction: column; + flex: 0 0 auto; + height: 200px; + /* Lock font size to prevent Sphinx responsive scaling */ + font-size: 12px; +} + +.sampling-profiler-viz .sampling-header { + padding: 8px 10px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.sampling-profiler-viz .sampling-title { + font: 600 10px var(--font-mono); + color: var(--text-primary); + margin: 0 0 3px 0; +} + +.sampling-profiler-viz .sampling-stats { + font: 400 9px var(--font-mono); + color: var(--text-secondary); + display: flex; + gap: 12px; +} + +.sampling-profiler-viz .sampling-stats .missed { + color: var(--color-red); +} + +.sampling-profiler-viz .sampling-bars { + flex: 1; + padding: 10px 12px; + overflow-y: auto; +} + +.sampling-profiler-viz .sampling-bar-row { + display: flex; + align-items: center; + height: 22px; + gap: 8px; +} + +.sampling-profiler-viz .bar-label { + font: 500 8px var(--font-mono); + color: var(--text-primary); + flex-shrink: 0; + width: 60px; + overflow: hidden; + text-overflow: ellipsis; +} + +.sampling-profiler-viz .bar-container { + flex: 1; + height: 12px; + background: var(--border-color); + border-radius: 3px; + position: relative; + overflow: hidden; +} + +.sampling-profiler-viz .bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.2s ease; + min-width: 2px; +} + +.sampling-profiler-viz .bar-percent { + font: 500 8px var(--font-mono); + color: var(--text-secondary); + width: 36px; + text-align: right; + flex-shrink: 0; +} + +/* Impact effect circle */ +.impact-circle { + position: fixed; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--color-teal); + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 2000; +} + +/* Control Panel - Integrated */ +.sampling-profiler-viz #control-panel { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--bg-panel); + border-top: 1px solid var(--border-color); + flex-shrink: 0; + flex-wrap: wrap; +} + +.sampling-profiler-viz .control-group { + display: flex; + gap: 6px; + align-items: center; +} + +.sampling-profiler-viz .control-group label { + font-size: 10px; + color: var(--text-muted); + font-weight: 500; + white-space: nowrap; +} + +.sampling-profiler-viz .control-btn { + background: var(--bg-panel); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 6px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s ease; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; +} + +.sampling-profiler-viz .control-btn:hover { + background: var(--bg-subtle); + border-color: var(--text-muted); +} + +.sampling-profiler-viz .control-btn:active { + transform: scale(0.98); +} + +.sampling-profiler-viz .control-btn.active { + background: var(--color-python-blue); + color: white; + border-color: var(--color-python-blue); +} + +.sampling-profiler-viz .control-btn.active:hover { + background: #2f6493; +} + +/* Timeline Scrubber */ +.sampling-profiler-viz .timeline-scrubber { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 160px; +} + +.sampling-profiler-viz #timeline-scrubber { + flex: 1; + height: 5px; + border-radius: 3px; + background: var(--border-color); + outline: none; + appearance: none; + -webkit-appearance: none; + cursor: pointer; + min-width: 60px; +} + +.sampling-profiler-viz #timeline-scrubber::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-python-blue); + cursor: pointer; + transition: transform 0.15s; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} + +.sampling-profiler-viz #timeline-scrubber::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + +.sampling-profiler-viz #timeline-scrubber::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-python-blue); + cursor: pointer; + border: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} + +.sampling-profiler-viz #time-display { + font: 500 10px var(--font-mono); + color: var(--text-secondary); + min-width: 90px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Sample Interval Slider */ +.sampling-profiler-viz #sample-interval { + width: 80px; + height: 4px; + border-radius: 2px; + background: var(--border-color); + outline: none; + appearance: none; + -webkit-appearance: none; + cursor: pointer; + flex-shrink: 0; +} + +.sampling-profiler-viz #sample-interval::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-teal); + cursor: pointer; + transition: transform 0.15s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.sampling-profiler-viz #sample-interval::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + +.sampling-profiler-viz #sample-interval::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-teal); + cursor: pointer; + border: none; +} + +.sampling-profiler-viz #interval-display { + font: 500 9px var(--font-mono); + color: var(--text-secondary); + min-width: 40px; + font-variant-numeric: tabular-nums; +} + +/* Flash overlay */ +.sampling-profiler-viz .flash-overlay { + position: absolute; + inset: 0; + background: white; + pointer-events: none; + opacity: 0; +} + +/* Performance optimizations */ +.sampling-profiler-viz .stack-frame, +.sampling-profiler-viz .flying-frame, +.sampling-profiler-viz .sampling-bar-row { + will-change: transform, opacity; + contain: layout style paint; +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .sampling-profiler-viz .stack-frame, + .sampling-profiler-viz .flying-frame, + .sampling-profiler-viz .sampling-bar-row, + .impact-circle { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +/* Responsive adjustments for narrower viewports */ +@media (max-width: 800px) { + .sampling-profiler-viz { + grid-template-columns: 280px 1fr; + --container-height: 550px; + } + + .sampling-profiler-viz #code-panel { + font-size: 11px; + } + + .sampling-profiler-viz .control-btn { + padding: 5px 8px; + font-size: 10px; + } +} diff --git a/Doc/_static/profiling-sampling-visualization.js b/Doc/_static/profiling-sampling-visualization.js new file mode 100644 index 00000000000000..95cc4ae44baffc --- /dev/null +++ b/Doc/_static/profiling-sampling-visualization.js @@ -0,0 +1,1264 @@ +/** + * Sampling Profiler Visualization + */ +(function () { + "use strict"; + + // ============================================================================ + // Configuration + // ============================================================================ + + const COLORS = { + // Tachyon logo-inspired colors + tachyonBlue: 0x306998, + tachyonGold: 0xd4a910, + // Docs-matching colors + background: 0xffffff, + panelBg: 0xffffff, + borderLight: 0xe1e4e8, + borderHighlight: 0x306998, + textPrimary: 0x0d0d0d, + textSecondary: 0x505050, + textDim: 0x6e6e6e, + // Function colors - blue/gold theme + funcMain: 0x306998, + funcFibonacci: 0xd4a910, + active: 0xfff9e6, + activeText: 0x856404, + success: 0x388e3c, + warning: 0xe65100, + error: 0xc62828, + info: 0x306998, + samplingAccent: 0x00897b, + tracingAccent: 0xd4a910, + overheadLow: 0x388e3c, + overheadMedium: 0xe65100, + overheadHigh: 0xc62828, + }; + + const TIMINGS = { + frameSlideIn: 500, + frameSlideOut: 300, + frameFadeOut: 200, + sampleFlash: 200, + sampleIntervalMin: 100, + sampleIntervalMax: 500, + sampleIntervalDefault: 200, + sampleToFlame: 600, + flameGrowth: 300, + hookDelay: 10, + eventLightDuration: 150, + speeds: [0.1, 0.25, 0.5, 1, 2, 5], + defaultSpeed: 0.05, + }; + + const LAYOUT = { + frameWidth: 200, + frameHeight: 40, + frameSpacing: 6, + frameRadius: 4, + codePanelWidth: 0.3, + stackPanelWidth: 0.4, + timelinePanelWidth: 0.3, + flameNodeHeight: 30, + flameMaxDepth: 20, + }; + + // ============================================================================ + // Color Utilities + // ============================================================================ + + function hexToCSS(hex) { + return "#" + hex.toString(16).padStart(6, "0").toUpperCase(); + } + + function getFunctionColor(funcName) { + // Blue for main, gold for other functions - matching Tachyon logo + if (funcName === "main") return hexToCSS(COLORS.tachyonBlue); + if (funcName === "fibonacci") return hexToCSS(COLORS.tachyonGold); + if (funcName === "add") return "#E65100"; // Orange + if (funcName === "multiply") return "#7B1FA2"; // Purple + if (funcName === "calculate") return hexToCSS(COLORS.tachyonGold); + return hexToCSS(COLORS.tachyonBlue); + } + + // ============================================================================ + // Animation Manager + // ============================================================================ + + class AnimationManager { + constructor() { + this.activeAnimations = new Set(); + } + + to(element, props, duration, easing = "easeOutQuad", onComplete = null) { + this.killAnimationsOf(element); + + // Cubic-bezier approximations of Robert Penner's easing equations. + // See: https://easings.net/ for visual references. + // Format: cubic-bezier(x1, y1, x2, y2) defines control points for the curve. + const easingMap = { + linear: "linear", + easeInQuad: "cubic-bezier(0.55, 0.085, 0.68, 0.53)", + easeOutQuad: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", + easeInOutQuad: "cubic-bezier(0.455, 0.03, 0.515, 0.955)", + easeInCubic: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", + easeOutCubic: "cubic-bezier(0.215, 0.61, 0.355, 1)", + easeInOutCubic: "cubic-bezier(0.645, 0.045, 0.355, 1)", + easeOutElastic: "cubic-bezier(0.68, -0.55, 0.265, 1.55)", + easeOutBack: "cubic-bezier(0.175, 0.885, 0.32, 1.275)", + easeOutBounce: "cubic-bezier(0.68, -0.25, 0.265, 1.25)", + }; + + const cssEasing = easingMap[easing] || easingMap.easeOutQuad; + + const transformProps = {}; + const otherProps = {}; + + for (const [key, value] of Object.entries(props)) { + if (key === "position" || key === "x" || key === "y") { + if (key === "position") { + if (typeof value.x === "number") transformProps.x = value.x; + if (typeof value.y === "number") transformProps.y = value.y; + } else if (key === "x") { + transformProps.x = value; + } else if (key === "y") { + transformProps.y = value; + } + } else if (key === "scale") { + transformProps.scale = value; + } else if (key === "alpha" || key === "opacity") { + otherProps.opacity = value; + } else { + otherProps[key] = value; + } + } + + const computedStyle = getComputedStyle(element); + const matrix = new DOMMatrix(computedStyle.transform); + + if (transformProps.x === undefined) transformProps.x = matrix.m41; + if (transformProps.y === undefined) transformProps.y = matrix.m42; + if (transformProps.scale === undefined) { + transformProps.scale = Math.sqrt( + matrix.m11 * matrix.m11 + matrix.m21 * matrix.m21, + ); + } + + const initialTransform = this._buildTransformString( + matrix.m41, + matrix.m42, + Math.sqrt(matrix.m11 * matrix.m11 + matrix.m21 * matrix.m21), + ); + + const finalTransform = this._buildTransformString( + transformProps.x !== undefined ? transformProps.x : matrix.m41, + transformProps.y !== undefined ? transformProps.y : matrix.m42, + transformProps.scale !== undefined ? transformProps.scale : 1, + ); + + const initialKeyframe = { transform: initialTransform }; + const finalKeyframe = { transform: finalTransform }; + + for (const [key, value] of Object.entries(otherProps)) { + const currentVal = + key === "opacity" + ? element.style.opacity || computedStyle.opacity + : element.style[key]; + initialKeyframe[key] = currentVal; + finalKeyframe[key] = value; + } + + const animation = element.animate([initialKeyframe, finalKeyframe], { + duration, + easing: cssEasing, + fill: "forwards", + }); + + this.activeAnimations.add(animation); + animation.onfinish = () => { + this.activeAnimations.delete(animation); + element.style.transform = finalTransform; + for (const [key, value] of Object.entries(finalKeyframe)) { + if (key !== "transform") { + element.style[key] = typeof value === "number" ? `${value}` : value; + } + } + if (onComplete) onComplete(); + }; + + return animation; + } + + killAnimationsOf(element) { + element.getAnimations().forEach((animation) => animation.cancel()); + this.activeAnimations.forEach((animation) => { + if (animation.effect && animation.effect.target === element) { + animation.cancel(); + this.activeAnimations.delete(animation); + } + }); + } + + _buildTransformString(x, y, scale = 1) { + return `translate(${x}px, ${y}px) scale(${scale})`; + } + } + + const anim = new AnimationManager(); + + // ============================================================================ + // Execution Trace Model + // ============================================================================ + + class ExecutionEvent { + constructor( + type, + functionName, + lineno, + timestamp, + args = null, + value = null, + ) { + this.type = type; + this.functionName = functionName; + this.lineno = lineno; + this.timestamp = timestamp; + this.args = args; + this.value = value; + } + } + + class ExecutionTrace { + constructor(source, events) { + this.source = source; + this.events = events.map( + (e) => + new ExecutionEvent(e.type, e.func, e.line, e.ts, e.args, e.value), + ); + this.duration = events.length > 0 ? events[events.length - 1].ts : 0; + } + + getEventsUntil(timestamp) { + return this.events.filter((e) => e.timestamp <= timestamp); + } + + getStackAt(timestamp) { + const stack = []; + const events = this.getEventsUntil(timestamp); + + for (const event of events) { + if (event.type === "call") { + stack.push({ + func: event.functionName, + line: event.lineno, + args: event.args, + }); + } else if (event.type === "return") { + stack.pop(); + } else if (event.type === "line") { + if (stack.length > 0) { + stack[stack.length - 1].line = event.lineno; + } + } + } + return stack; + } + + getNextEvent(timestamp) { + return this.events.find((e) => e.timestamp > timestamp); + } + + getSourceLines() { + return this.source.split("\n"); + } + } + + // ============================================================================ + // Demo Data + // ============================================================================ + + // This placeholder is replaced by the profiling_trace Sphinx extension + // during the documentation build with dynamically generated trace data. + const DEMO_SIMPLE = /* PROFILING_TRACE_DATA */ null; + + // ============================================================================ + // Code Panel Component + // ============================================================================ + + class CodePanel { + constructor(source) { + this.source = source; + this.currentLine = null; + + this.element = document.createElement("div"); + this.element.id = "code-panel"; + + const title = document.createElement("div"); + title.className = "code-panel-title"; + title.textContent = "source code"; + this.element.appendChild(title); + + this.codeContainer = document.createElement("pre"); + this.codeContainer.className = "code-container"; + this.element.appendChild(this.codeContainer); + + this._renderSource(); + } + + updateSource(source) { + this.source = source; + this.codeContainer.innerHTML = ""; + this._renderSource(); + this.currentLine = null; + } + + _renderSource() { + const lines = this.source.split("\n"); + + lines.forEach((line, index) => { + const lineNumber = index + 1; + const lineDiv = document.createElement("div"); + lineDiv.className = "line"; + lineDiv.dataset.line = lineNumber; + + const lineNumSpan = document.createElement("span"); + lineNumSpan.className = "line-number"; + lineNumSpan.textContent = lineNumber; + lineDiv.appendChild(lineNumSpan); + + const codeSpan = document.createElement("span"); + codeSpan.className = "line-content"; + codeSpan.innerHTML = this._highlightSyntax(line); + lineDiv.appendChild(codeSpan); + + this.codeContainer.appendChild(lineDiv); + }); + } + + _highlightSyntax(line) { + let highlighted = line + .replace(/&/g, "&") + .replace(//g, ">"); + highlighted = highlighted.replace( + /(f?"[^"]*"|f?'[^']*')/g, + '$1', + ); + highlighted = highlighted.replace( + /(#.*$)/g, + '$1', + ); + const keywords = + /\b(def|if|elif|else|return|for|in|range|print|__name__|__main__)\b/g; + highlighted = highlighted.replace( + keywords, + '$1', + ); + highlighted = highlighted.replace( + /def<\/span>\s+(\w+)/g, + 'def $1', + ); + highlighted = highlighted.replace( + /\b(\d+)\b/g, + '$1', + ); + return highlighted; + } + + highlightLine(lineNumber) { + if (this.currentLine === lineNumber) return; + + if (this.currentLine !== null) { + const prevLine = this.codeContainer.querySelector( + `[data-line="${this.currentLine}"]`, + ); + if (prevLine) prevLine.classList.remove("highlighted"); + } + + if (lineNumber === null || lineNumber === undefined) { + this.currentLine = null; + return; + } + + this.currentLine = lineNumber; + const newLine = this.codeContainer.querySelector( + `[data-line="${lineNumber}"]`, + ); + if (newLine) { + newLine.classList.add("highlighted"); + } + } + + _scrollToLine(lineElement) { + const containerRect = this.codeContainer.getBoundingClientRect(); + const lineRect = lineElement.getBoundingClientRect(); + const isAbove = lineRect.top < containerRect.top + 50; + const isBelow = lineRect.bottom > containerRect.bottom - 50; + + if (isAbove || isBelow) { + // Scroll within the container only, not the page + const lineTop = lineElement.offsetTop; + const containerHeight = this.codeContainer.clientHeight; + const targetScroll = + lineTop - containerHeight / 2 + lineElement.offsetHeight / 2; + this.codeContainer.scrollTop = Math.max(0, targetScroll); + } + } + + reset() { + this.highlightLine(null); + this.codeContainer.scrollTop = 0; + } + + destroy() { + this.element.remove(); + } + } + + // ============================================================================ + // Stack Frame Component + // ============================================================================ + + class DOMStackFrame { + constructor(functionName, lineno, args = null) { + this.functionName = functionName; + this.lineno = lineno; + this.args = args; + this.isActive = false; + this.color = getFunctionColor(functionName); + + this.element = document.createElement("div"); + this.element.className = "stack-frame"; + this.element.dataset.function = functionName; + + const bg = document.createElement("div"); + bg.className = "stack-frame-bg"; + bg.style.backgroundColor = this.color; + this.element.appendChild(bg); + + this.textElement = document.createElement("span"); + this.textElement.className = "stack-frame-text"; + this.textElement.textContent = functionName; + this.element.appendChild(this.textElement); + + this.flashElement = document.createElement("div"); + this.flashElement.className = "stack-frame-flash"; + this.element.appendChild(this.flashElement); + + this.element.addEventListener("pointerover", this._onHover.bind(this)); + this.element.addEventListener("pointerout", this._onHoverOut.bind(this)); + } + + destroy() { + if (this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + } + + updateLine(lineno) { + this.lineno = lineno; + this.textElement.textContent = this.functionName; + } + + setActive(isActive) { + if (this.isActive === isActive) return; + this.isActive = isActive; + const bg = this.element.querySelector(".stack-frame-bg"); + bg.style.opacity = isActive ? "1.0" : "0.9"; + } + + _onHover() { + const bg = this.element.querySelector(".stack-frame-bg"); + bg.style.opacity = "0.8"; + } + + _onHoverOut() { + const bg = this.element.querySelector(".stack-frame-bg"); + bg.style.opacity = this.isActive ? "1.0" : "0.9"; + } + + flash(duration = 150) { + this.flashElement.animate([{ opacity: 1 }, { opacity: 0 }], { + duration, + easing: "ease-out", + }); + } + + getPosition() { + const rect = this.element.getBoundingClientRect(); + return { x: rect.left, y: rect.top }; + } + } + + // ============================================================================ + // Stack Visualization Component + // ============================================================================ + + class DOMStackVisualization { + constructor() { + this.frames = []; + this.frameSpacing = LAYOUT.frameSpacing; + + this.element = document.createElement("div"); + this.element.className = "stack-visualization"; + } + + processEvent(event) { + if (event.type === "call") { + this.pushFrame(event.functionName, event.lineno, event.args); + } else if (event.type === "return") { + this.popFrame(); + } else if (event.type === "line") { + this.updateTopFrameLine(event.lineno); + } + } + + updateTopFrameLine(lineno) { + if (this.frames.length > 0) { + this.frames[this.frames.length - 1].updateLine(lineno); + } + } + + pushFrame(functionName, lineno, args = null) { + if (this.frames.length > 0) { + this.frames[this.frames.length - 1].setActive(false); + } + + const frame = new DOMStackFrame(functionName, lineno, args); + frame.setActive(true); + this.element.appendChild(frame.element); + this.frames.push(frame); + + requestAnimationFrame(() => { + frame.element.classList.add("visible"); + }); + } + + popFrame() { + if (this.frames.length === 0) return; + + const frame = this.frames.pop(); + frame.element.classList.remove("visible"); + setTimeout(() => frame.destroy(), 300); + + if (this.frames.length > 0) { + this.frames[this.frames.length - 1].setActive(true); + } + } + + clear() { + this.frames.forEach((frame) => frame.destroy()); + this.frames = []; + this.element.innerHTML = ""; + } + + flashAll() { + this.frames.forEach((frame) => frame.flash()); + } + + createStackClone(container) { + const clone = this.element.cloneNode(false); + clone.className = "stack-visualization flying-clone"; + + const elementRect = this.element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + // Position relative to container since contain: strict makes position:fixed relative to container + clone.style.position = "absolute"; + clone.style.left = elementRect.left - containerRect.left + "px"; + clone.style.top = elementRect.top - containerRect.top + "px"; + clone.style.width = elementRect.width + "px"; + clone.style.pointerEvents = "none"; + clone.style.zIndex = "1000"; + + this.frames.forEach((frame) => { + const frameClone = frame.element.cloneNode(true); + frameClone.classList.add("visible"); + frameClone.style.opacity = "1"; + frameClone.style.transform = "translateY(0)"; + frameClone.style.transition = "none"; + clone.appendChild(frameClone); + }); + + container.appendChild(clone); + return clone; + } + + updateToMatch(targetStack) { + while (this.frames.length > targetStack.length) { + this.popFrame(); + } + + targetStack.forEach(({ func, line, args }, index) => { + if (index < this.frames.length) { + const frame = this.frames[index]; + if (frame.functionName !== func) { + frame.updateLine(line); + } + frame.setActive(index === targetStack.length - 1); + } else { + this.pushFrame(func, line, args); + } + }); + + if (this.frames.length > 0) { + this.frames[this.frames.length - 1].setActive(true); + } + } + } + + // ============================================================================ + // Sampling Panel Component + // ============================================================================ + + class DOMSamplingPanel { + constructor() { + this.samples = []; + this.functionCounts = {}; + this.totalSamples = 0; + this.sampleInterval = TIMINGS.sampleIntervalDefault; + this.groundTruthFunctions = new Set(); + this.bars = {}; + + this.element = document.createElement("div"); + this.element.className = "sampling-panel"; + + const header = document.createElement("div"); + header.className = "sampling-header"; + + const title = document.createElement("h3"); + title.className = "sampling-title"; + title.textContent = "Sampling Profiler"; + header.appendChild(title); + + const stats = document.createElement("div"); + stats.className = "sampling-stats"; + + this.sampleCountEl = document.createElement("span"); + this.sampleCountEl.textContent = "Samples: 0"; + stats.appendChild(this.sampleCountEl); + + this.intervalEl = document.createElement("span"); + this.intervalEl.textContent = `Interval: ${this.sampleInterval}ms`; + stats.appendChild(this.intervalEl); + + this.missedFunctionsEl = document.createElement("span"); + this.missedFunctionsEl.className = "missed"; + stats.appendChild(this.missedFunctionsEl); + + header.appendChild(stats); + this.element.appendChild(header); + + this.barsContainer = document.createElement("div"); + this.barsContainer.className = "sampling-bars"; + this.element.appendChild(this.barsContainer); + } + + setSampleInterval(interval) { + this.sampleInterval = interval; + this.intervalEl.textContent = `Interval: ${interval}ms`; + } + + setGroundTruth(allFunctions) { + this.groundTruthFunctions = new Set(allFunctions); + this._updateMissedCount(); + } + + addSample(stack) { + this.totalSamples++; + this.sampleCountEl.textContent = `Samples: ${this.totalSamples}`; + + stack.forEach((frame) => { + const funcName = frame.func; + this.functionCounts[funcName] = + (this.functionCounts[funcName] || 0) + 1; + }); + + this._updateBars(); + this._updateMissedCount(); + } + + reset() { + this.samples = []; + this.functionCounts = {}; + this.totalSamples = 0; + this.sampleCountEl.textContent = "Samples: 0"; + this.missedFunctionsEl.textContent = ""; + this.barsContainer.innerHTML = ""; + this.bars = {}; + } + + _updateMissedCount() { + if (this.groundTruthFunctions.size === 0) return; + + const capturedFunctions = new Set(Object.keys(this.functionCounts)); + const notYetSeen = [...this.groundTruthFunctions].filter( + (f) => !capturedFunctions.has(f), + ); + + if (notYetSeen.length > 0) { + this.missedFunctionsEl.textContent = `Not yet seen: ${notYetSeen.length}`; + this.missedFunctionsEl.classList.add("missed"); + this.missedFunctionsEl.style.color = ""; + } else if (this.totalSamples > 0) { + this.missedFunctionsEl.textContent = "All captured!"; + this.missedFunctionsEl.classList.remove("missed"); + this.missedFunctionsEl.style.color = "var(--color-green)"; + } else { + this.missedFunctionsEl.textContent = ""; + } + } + + _updateBars() { + const sorted = Object.entries(this.functionCounts).sort( + (a, b) => b[1] - a[1], + ); + + sorted.forEach(([funcName, count], index) => { + const percentage = + this.totalSamples > 0 ? count / this.totalSamples : 0; + + if (!this.bars[funcName]) { + const row = this._createBarRow(funcName); + this.barsContainer.appendChild(row); + this.bars[funcName] = row; + } + + const row = this.bars[funcName]; + const barFill = row.querySelector(".bar-fill"); + barFill.style.width = `${percentage * 100}%`; + + const percentEl = row.querySelector(".bar-percent"); + percentEl.textContent = `${(percentage * 100).toFixed(0)}%`; + + const currentIndex = Array.from(this.barsContainer.children).indexOf( + row, + ); + if (currentIndex !== index) { + this.barsContainer.insertBefore( + row, + this.barsContainer.children[index], + ); + } + }); + } + + _createBarRow(funcName) { + const row = document.createElement("div"); + row.className = "sampling-bar-row"; + row.dataset.function = funcName; + + const label = document.createElement("span"); + label.className = "bar-label"; + label.textContent = funcName; + row.appendChild(label); + + const barContainer = document.createElement("div"); + barContainer.className = "bar-container"; + + const barFill = document.createElement("div"); + barFill.className = "bar-fill"; + barFill.style.backgroundColor = getFunctionColor(funcName); + barContainer.appendChild(barFill); + + row.appendChild(barContainer); + + const percent = document.createElement("span"); + percent.className = "bar-percent"; + percent.textContent = "0%"; + row.appendChild(percent); + + return row; + } + + getTargetPosition() { + const rect = this.barsContainer.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + 50 }; + } + + showImpactEffect(position) { + const impact = document.createElement("div"); + impact.className = "impact-circle"; + impact.style.position = "fixed"; + impact.style.left = `${position.x}px`; + impact.style.top = `${position.y}px`; + + // Append to barsContainer parent to avoid triggering scroll + this.element.appendChild(impact); + + impact.animate( + [ + { transform: "translate(-50%, -50%) scale(1)", opacity: 0.6 }, + { transform: "translate(-50%, -50%) scale(4)", opacity: 0 }, + ], + { + duration: 300, + easing: "ease-out", + }, + ).onfinish = () => impact.remove(); + } + } + + // ============================================================================ + // Control Panel Component + // ============================================================================ + + class ControlPanel { + constructor( + container, + onPlay, + onPause, + onReset, + onSpeedChange, + onSeek, + onStep, + onSampleIntervalChange = null, + ) { + this.container = container; + this.onPlay = onPlay; + this.onPause = onPause; + this.onReset = onReset; + this.onSpeedChange = onSpeedChange; + this.onSeek = onSeek; + this.onStep = onStep; + this.onSampleIntervalChange = onSampleIntervalChange; + + this.isPlaying = false; + this.speed = TIMINGS.defaultSpeed; + + this._createControls(); + } + + _createControls() { + const panel = document.createElement("div"); + panel.id = "control-panel"; + + const sampleIntervalHtml = this.onSampleIntervalChange + ? ` +
+ + + ${TIMINGS.sampleIntervalDefault}ms +
+ ` + : ""; + + panel.innerHTML = ` +
+ + + +
+ + ${sampleIntervalHtml} + +
+ + 0ms +
+ `; + + this.container.appendChild(panel); + + this.playPauseBtn = panel.querySelector("#play-pause-btn"); + this.resetBtn = panel.querySelector("#reset-btn"); + this.stepBtn = panel.querySelector("#step-btn"); + this.scrubber = panel.querySelector("#timeline-scrubber"); + this.timeDisplay = panel.querySelector("#time-display"); + + this.playPauseBtn.addEventListener("click", () => + this._togglePlayPause(), + ); + this.resetBtn.addEventListener("click", () => this._handleReset()); + this.stepBtn.addEventListener("click", () => this._handleStep()); + this.scrubber.addEventListener("input", (e) => this._handleSeek(e)); + + if (this.onSampleIntervalChange) { + this.sampleIntervalSlider = panel.querySelector("#sample-interval"); + this.intervalDisplay = panel.querySelector("#interval-display"); + this.sampleIntervalSlider.addEventListener("input", (e) => + this._handleSampleIntervalChange(e), + ); + } + } + + _handleSampleIntervalChange(e) { + const interval = parseInt(e.target.value); + this.intervalDisplay.textContent = `${interval}ms`; + this.onSampleIntervalChange(interval); + } + + _togglePlayPause() { + this.isPlaying = !this.isPlaying; + + if (this.isPlaying) { + this.playPauseBtn.textContent = "⏸ Pause"; + this.playPauseBtn.classList.add("active"); + this.onPlay(); + } else { + this.playPauseBtn.textContent = "▶ Play"; + this.playPauseBtn.classList.remove("active"); + this.onPause(); + } + } + + _handleReset() { + this.isPlaying = false; + this.playPauseBtn.textContent = "▶ Play"; + this.playPauseBtn.classList.remove("active"); + this.scrubber.value = 0; + this.timeDisplay.textContent = "0ms"; + this.onReset(); + } + + _handleStep() { + if (this.onStep) this.onStep(); + } + + _handleSeek(e) { + const percentage = parseFloat(e.target.value); + this.onSeek(percentage / 100); + } + + updateTimeDisplay(currentTime, totalTime) { + this.timeDisplay.textContent = `${Math.floor(currentTime)}ms / ${Math.floor(totalTime)}ms`; + const percentage = (currentTime / totalTime) * 100; + this.scrubber.value = percentage; + } + + setDuration(duration) { + this.duration = duration; + } + + pause() { + if (this.isPlaying) this._togglePlayPause(); + } + + destroy() { + const panel = this.container.querySelector("#control-panel"); + if (panel) panel.remove(); + } + } + + // ============================================================================ + // Visual Effects Manager + // ============================================================================ + + class VisualEffectsManager { + constructor(container) { + this.container = container; + this.flyingAnimationInProgress = false; + + this.flashOverlay = document.createElement("div"); + this.flashOverlay.className = "flash-overlay"; + this.container.appendChild(this.flashOverlay); + } + + isAnimating() { + return this.flyingAnimationInProgress; + } + + triggerSamplingEffect(stackViz, samplingPanel, currentTime, trace) { + if (this.flyingAnimationInProgress) return; + + const stack = trace.getStackAt(currentTime); + + if (stack.length === 0) { + samplingPanel.addSample(stack); + return; + } + + this.flyingAnimationInProgress = true; + stackViz.flashAll(); + + const clone = stackViz.createStackClone(this.container); + const targetPosition = samplingPanel.getTargetPosition(); + + this._animateFlash(); + this._animateFlyingStack(clone, targetPosition, () => { + samplingPanel.showImpactEffect(targetPosition); + clone.remove(); + + const currentStack = trace.getStackAt(currentTime); + samplingPanel.addSample(currentStack); + this.flyingAnimationInProgress = false; + }); + } + + _animateFlash() { + anim.to(this.flashOverlay, { opacity: 0.1 }, 0).onfinish = () => { + anim.to(this.flashOverlay, { opacity: 0 }, 150, "easeOutQuad"); + }; + } + + _animateFlyingStack(clone, targetPosition, onComplete) { + const containerRect = this.container.getBoundingClientRect(); + const cloneRect = clone.getBoundingClientRect(); + + // Convert viewport coordinates to container-relative + const startX = cloneRect.left - containerRect.left + cloneRect.width / 2; + const startY = cloneRect.top - containerRect.top + cloneRect.height / 2; + const targetX = targetPosition.x - containerRect.left; + const targetY = targetPosition.y - containerRect.top; + + const deltaX = targetX - startX; + const deltaY = targetY - startY; + + anim.to( + clone, + { + x: deltaX, + y: deltaY, + scale: 0.3, + opacity: 0.6, + }, + TIMINGS.sampleToFlame, + "easeOutCubic", + onComplete, + ); + } + } + + // ============================================================================ + // Main Visualization Class + // ============================================================================ + + class SamplingVisualization { + constructor(container) { + this.container = container; + + this.trace = new ExecutionTrace(DEMO_SIMPLE.source, DEMO_SIMPLE.trace); + + this.currentTime = 0; + this.isPlaying = false; + this.playbackSpeed = TIMINGS.defaultSpeed; + this.eventIndex = 0; + + this.sampleInterval = TIMINGS.sampleIntervalDefault; + this.lastSampleTime = 0; + + this._createLayout(); + + this.effectsManager = new VisualEffectsManager(this.vizColumn); + + this.lastTime = performance.now(); + this._animate(); + } + + _createLayout() { + this.codePanel = new CodePanel(this.trace.source); + this.container.appendChild(this.codePanel.element); + + this.vizColumn = document.createElement("div"); + this.vizColumn.className = "viz-column"; + this.container.appendChild(this.vizColumn); + + const stackSection = document.createElement("div"); + stackSection.className = "stack-section"; + + const stackTitle = document.createElement("div"); + stackTitle.className = "stack-section-title"; + stackTitle.textContent = "Call Stack"; + stackSection.appendChild(stackTitle); + + this.stackViz = new DOMStackVisualization(); + stackSection.appendChild(this.stackViz.element); + this.vizColumn.appendChild(stackSection); + + this.samplingPanel = new DOMSamplingPanel(); + this.samplingPanel.setGroundTruth(this._getGroundTruthFunctions()); + this.vizColumn.appendChild(this.samplingPanel.element); + + this.controls = new ControlPanel( + this.vizColumn, + () => this.play(), + () => this.pause(), + () => this.reset(), + (speed) => this.setSpeed(speed), + (progress) => this.seek(progress), + () => this.step(), + (interval) => this.setSampleInterval(interval), + ); + this.controls.setDuration(this.trace.duration); + } + + _getGroundTruthFunctions() { + const functions = new Set(); + this.trace.events.forEach((event) => { + if (event.type === "call") { + functions.add(event.functionName); + } + }); + return [...functions]; + } + + play() { + this.isPlaying = true; + } + + pause() { + this.isPlaying = false; + } + + reset() { + this.currentTime = 0; + this.eventIndex = 0; + this.isPlaying = false; + this.lastSampleTime = 0; + this.stackViz.clear(); + this.codePanel.reset(); + this.samplingPanel.reset(); + this.controls.updateTimeDisplay(0, this.trace.duration); + } + + setSpeed(speed) { + this.playbackSpeed = speed; + } + + setSampleInterval(interval) { + this.sampleInterval = interval; + this.samplingPanel.setSampleInterval(interval); + } + + seek(progress) { + this.currentTime = progress * this.trace.duration; + this.eventIndex = 0; + this.lastSampleTime = 0; + this._rebuildState(); + } + + step() { + this.pause(); + + const nextEvent = this.trace.getNextEvent(this.currentTime); + + if (nextEvent) { + // Calculate delta to reach next event + epsilon + const targetTime = nextEvent.timestamp + 0.1; + const delta = targetTime - this.currentTime; + if (delta > 0) { + this._advanceTime(delta); + } + } + } + + _animate(currentTime = performance.now()) { + const deltaTime = currentTime - this.lastTime; + this.lastTime = currentTime; + + this.update(deltaTime); + requestAnimationFrame((t) => this._animate(t)); + } + + update(deltaTime) { + if (!this.isPlaying) { + this.controls.updateTimeDisplay(this.currentTime, this.trace.duration); + return; + } + + const virtualDelta = deltaTime * this.playbackSpeed; + this._advanceTime(virtualDelta); + } + + _advanceTime(virtualDelta) { + this.currentTime += virtualDelta; + + if (this.currentTime >= this.trace.duration) { + this.currentTime = this.trace.duration; + this.isPlaying = false; + this.controls.pause(); + } + + while (this.eventIndex < this.trace.events.length) { + const event = this.trace.events[this.eventIndex]; + + if (event.timestamp > this.currentTime) break; + + this._processEvent(event); + this.eventIndex++; + } + + this.controls.updateTimeDisplay(this.currentTime, this.trace.duration); + + if (this.currentTime - this.lastSampleTime >= this.sampleInterval) { + this._takeSample(); + this.lastSampleTime = this.currentTime; + } + } + + _processEvent(event) { + this.stackViz.processEvent(event); + + if (event.type === "call") { + this.codePanel.highlightLine(event.lineno); + } else if (event.type === "return") { + const currentStack = this.trace.getStackAt(this.currentTime); + if (currentStack.length > 0) { + this.codePanel.highlightLine( + currentStack[currentStack.length - 1].line, + ); + } else { + this.codePanel.highlightLine(null); + } + } else if (event.type === "line") { + this.codePanel.highlightLine(event.lineno); + } + } + + _takeSample() { + this.effectsManager.triggerSamplingEffect( + this.stackViz, + this.samplingPanel, + this.currentTime, + this.trace, + ); + } + + _rebuildState() { + this.stackViz.clear(); + this.codePanel.reset(); + this.samplingPanel.reset(); + + for (let t = 0; t < this.currentTime; t += this.sampleInterval) { + const stack = this.trace.getStackAt(t); + this.samplingPanel.addSample(stack); + this.lastSampleTime = t; + } + + const stack = this.trace.getStackAt(this.currentTime); + this.stackViz.updateToMatch(stack); + + if (stack.length > 0) { + this.codePanel.highlightLine(stack[stack.length - 1].line); + } + + this.eventIndex = this.trace.getEventsUntil(this.currentTime).length; + } + } + + // ============================================================================ + // Initialize + // ============================================================================ + + function init() { + // If trace data hasn't been injected yet (local dev), don't initialize + if (!DEMO_SIMPLE) return; + + const appContainer = document.getElementById("sampling-profiler-viz"); + if (appContainer) { + new SamplingVisualization(appContainer); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/Doc/conf.py b/Doc/conf.py index a4275835059efa..5b2dba423ae8fd 100644 --- a/Doc/conf.py +++ b/Doc/conf.py @@ -33,6 +33,7 @@ 'issue_role', 'lexers', 'misc_news', + 'profiling_trace', 'pydoc_topics', 'pyspecific', 'sphinx.ext.coverage', diff --git a/Doc/library/profiling-sampling-visualization.html b/Doc/library/profiling-sampling-visualization.html new file mode 100644 index 00000000000000..3d0d6440a05d27 --- /dev/null +++ b/Doc/library/profiling-sampling-visualization.html @@ -0,0 +1,3 @@ + +
+ diff --git a/Doc/library/profiling.sampling.rst b/Doc/library/profiling.sampling.rst index a05adf8c3da20e..d179069cfed5f9 100644 --- a/Doc/library/profiling.sampling.rst +++ b/Doc/library/profiling.sampling.rst @@ -44,6 +44,23 @@ of samples over a profiling session, Tachyon constructs an accurate statistical estimate of where time is spent. The more samples collected, the more precise this estimate becomes. +.. only:: html + + The following interactive visualization demonstrates how sampling profiling + works. Press **Play** to watch a Python program execute, and observe how the + profiler periodically captures snapshots of the call stack. Adjust the + **sample interval** to see how sampling frequency affects the results. + + .. raw:: html + :file: profiling-sampling-visualization.html + +.. only:: not html + + .. note:: + + An interactive visualization of sampling profiling is available in the + HTML version of this documentation. + How time is estimated --------------------- diff --git a/Doc/tools/extensions/profiling_trace.py b/Doc/tools/extensions/profiling_trace.py new file mode 100644 index 00000000000000..35cc3d0844e7ee --- /dev/null +++ b/Doc/tools/extensions/profiling_trace.py @@ -0,0 +1,164 @@ +""" +Sphinx extension to generate profiler trace data during docs build. + +This extension executes a demo Python program with sys.settrace() to capture +the execution trace and injects it into the profiling visualization JS file. +""" + +import json +import re +import sys +from io import StringIO +from pathlib import Path + +from sphinx.errors import ExtensionError + +DEMO_SOURCE = """\ +def add(a, b): + return a + b + +def multiply(x, y): + result = 0 + for i in range(y): + result = add(result, x) + return result + +def calculate(a, b): + sum_val = add(a, b) + product = multiply(a, b) + return sum_val + product + +def main(): + result = calculate(3, 4) + print(f"Result: {result}") + +main() +""" + +PLACEHOLDER = "/* PROFILING_TRACE_DATA */null" + + +def generate_trace(source: str) -> list[dict]: + """ + Execute the source code with tracing enabled and capture execution events. + """ + trace_events = [] + timestamp = [0] + timestamp_step = 50 + tracing_active = [False] + pending_line = [None] + + def tracer(frame, event, arg): + if frame.f_code.co_filename != '': + return tracer + + func_name = frame.f_code.co_name + lineno = frame.f_lineno + + if event == 'line' and not tracing_active[0]: + pending_line[0] = {'type': 'line', 'line': lineno} + return tracer + + # Start tracing only once main() is called + if event == 'call' and func_name == 'main': + tracing_active[0] = True + # Emit the buffered line event (the main() call line) at ts=0 + if pending_line[0]: + pending_line[0]['ts'] = 0 + trace_events.append(pending_line[0]) + pending_line[0] = None + timestamp[0] = timestamp_step + + # Skip events until we've entered main() + if not tracing_active[0]: + return tracer + + if event == 'call': + trace_events.append({ + 'type': 'call', + 'func': func_name, + 'line': lineno, + 'ts': timestamp[0], + }) + elif event == 'line': + trace_events.append({ + 'type': 'line', + 'line': lineno, + 'ts': timestamp[0], + }) + elif event == 'return': + try: + value = arg if arg is None else repr(arg) + except Exception: + value = '' + trace_events.append({ + 'type': 'return', + 'func': func_name, + 'ts': timestamp[0], + 'value': value, + }) + + if func_name == 'main': + tracing_active[0] = False + + timestamp[0] += timestamp_step + return tracer + + # Suppress print output during tracing + old_stdout = sys.stdout + sys.stdout = StringIO() + + old_trace = sys.gettrace() + sys.settrace(tracer) + try: + code = compile(source, '', 'exec') + exec(code, {'__name__': '__main__'}) + finally: + sys.settrace(old_trace) + sys.stdout = old_stdout + + return trace_events + + +def inject_trace(app, exception): + if exception: + return + + js_path = ( + Path(app.outdir) / '_static' / 'profiling-sampling-visualization.js' + ) + + if not js_path.exists(): + return + + trace = generate_trace(DEMO_SOURCE) + + demo_data = {'source': DEMO_SOURCE.rstrip(), 'trace': trace, 'samples': []} + + demo_json = json.dumps(demo_data, indent=2) + content = js_path.read_text(encoding='utf-8') + + pattern = r"(const DEMO_SIMPLE\s*=\s*/\* PROFILING_TRACE_DATA \*/)[^;]+;" + + if re.search(pattern, content): + content = re.sub( + pattern, lambda m: f"{m.group(1)} {demo_json};", content + ) + js_path.write_text(content, encoding='utf-8') + print( + f"profiling_trace: Injected {len(trace)} trace events into {js_path.name}" + ) + else: + raise ExtensionError( + f"profiling_trace: Placeholder pattern not found in {js_path.name}" + ) + + +def setup(app): + app.connect('build-finished', inject_trace) + + return { + 'version': '1.0', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } From 6535df58f667d3f6f7934b18369b815b8401ee20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Tue, 16 Dec 2025 10:33:12 +0000 Subject: [PATCH 2/4] Fix unclosed tag --- Doc/library/profiling-sampling-visualization.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/profiling-sampling-visualization.html b/Doc/library/profiling-sampling-visualization.html index 3d0d6440a05d27..edf797f0d2044b 100644 --- a/Doc/library/profiling-sampling-visualization.html +++ b/Doc/library/profiling-sampling-visualization.html @@ -1,3 +1,3 @@ - +
From 65048f0fe2db9d522104cacbd4d296d8f7be9e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Kiss=20Koll=C3=A1r?= Date: Tue, 16 Dec 2025 10:54:03 +0000 Subject: [PATCH 3/4] Fix epub export issue Include the JS and CSS files in the Sphinx extension to make sure Sphinx properly includes these when producing all output types. --- Doc/library/profiling-sampling-visualization.html | 2 -- Doc/tools/extensions/profiling_trace.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/profiling-sampling-visualization.html b/Doc/library/profiling-sampling-visualization.html index edf797f0d2044b..0cbd0f2374deaa 100644 --- a/Doc/library/profiling-sampling-visualization.html +++ b/Doc/library/profiling-sampling-visualization.html @@ -1,3 +1 @@ -
- diff --git a/Doc/tools/extensions/profiling_trace.py b/Doc/tools/extensions/profiling_trace.py index 35cc3d0844e7ee..7185ef351ddc7f 100644 --- a/Doc/tools/extensions/profiling_trace.py +++ b/Doc/tools/extensions/profiling_trace.py @@ -156,6 +156,8 @@ def inject_trace(app, exception): def setup(app): app.connect('build-finished', inject_trace) + app.add_js_file('profiling-sampling-visualization.js') + app.add_css_file('profiling-sampling-visualization.css') return { 'version': '1.0', From e34d6079f7cf6d98f4b0b4fbb11da6252f339a5a Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Wed, 17 Dec 2025 13:39:17 +0000 Subject: [PATCH 4/4] Simplify sampling profiler visualization code Moved function colors to a lookup map and easing definitions to a module constant so they aren't recreated on each call. Cached the background element reference in DOMStackFrame instead of querying it on every hover. Chained the regex replacements in syntax highlighting into one expression. Removed the CSS color rules for specific functions since colors are now set in JS, along with a duplicate color variable and two methods that were never called. --- .../profiling-sampling-visualization.css | 23 +- .../profiling-sampling-visualization.js | 209 +++++------------- 2 files changed, 55 insertions(+), 177 deletions(-) diff --git a/Doc/_static/profiling-sampling-visualization.css b/Doc/_static/profiling-sampling-visualization.css index 5259236359b19e..6bfbec3b8a6044 100644 --- a/Doc/_static/profiling-sampling-visualization.css +++ b/Doc/_static/profiling-sampling-visualization.css @@ -19,9 +19,8 @@ --text-muted: #6e6e6e; --text-code: #333333; - /* Accent colors - matching Tachyon logo */ + /* Accent colors */ --color-python-blue: #306998; - --color-tachyon-gold: #d4a910; --color-green: #388e3c; --color-orange: #e65100; --color-purple: #7b1fa2; @@ -229,26 +228,6 @@ transition: opacity 0.15s; } -.sampling-profiler-viz .stack-frame[data-function="main"] .stack-frame-bg { - background: var(--color-python-blue); -} - -.sampling-profiler-viz .stack-frame[data-function="fibonacci"] .stack-frame-bg { - background: var(--color-tachyon-gold); -} - -.sampling-profiler-viz .stack-frame[data-function="add"] .stack-frame-bg { - background: var(--color-orange); -} - -.sampling-profiler-viz .stack-frame[data-function="multiply"] .stack-frame-bg { - background: var(--color-purple); -} - -.sampling-profiler-viz .stack-frame[data-function="calculate"] .stack-frame-bg { - background: var(--color-tachyon-gold); -} - .sampling-profiler-viz .stack-frame-text { position: absolute; left: 10px; diff --git a/Doc/_static/profiling-sampling-visualization.js b/Doc/_static/profiling-sampling-visualization.js index 95cc4ae44baffc..3729be6795c1c8 100644 --- a/Doc/_static/profiling-sampling-visualization.js +++ b/Doc/_static/profiling-sampling-visualization.js @@ -8,78 +8,35 @@ // Configuration // ============================================================================ - const COLORS = { - // Tachyon logo-inspired colors - tachyonBlue: 0x306998, - tachyonGold: 0xd4a910, - // Docs-matching colors - background: 0xffffff, - panelBg: 0xffffff, - borderLight: 0xe1e4e8, - borderHighlight: 0x306998, - textPrimary: 0x0d0d0d, - textSecondary: 0x505050, - textDim: 0x6e6e6e, - // Function colors - blue/gold theme - funcMain: 0x306998, - funcFibonacci: 0xd4a910, - active: 0xfff9e6, - activeText: 0x856404, - success: 0x388e3c, - warning: 0xe65100, - error: 0xc62828, - info: 0x306998, - samplingAccent: 0x00897b, - tracingAccent: 0xd4a910, - overheadLow: 0x388e3c, - overheadMedium: 0xe65100, - overheadHigh: 0xc62828, - }; - const TIMINGS = { - frameSlideIn: 500, - frameSlideOut: 300, - frameFadeOut: 200, - sampleFlash: 200, sampleIntervalMin: 100, sampleIntervalMax: 500, sampleIntervalDefault: 200, sampleToFlame: 600, - flameGrowth: 300, - hookDelay: 10, - eventLightDuration: 150, - speeds: [0.1, 0.25, 0.5, 1, 2, 5], defaultSpeed: 0.05, }; - const LAYOUT = { - frameWidth: 200, - frameHeight: 40, - frameSpacing: 6, - frameRadius: 4, - codePanelWidth: 0.3, - stackPanelWidth: 0.4, - timelinePanelWidth: 0.3, - flameNodeHeight: 30, - flameMaxDepth: 20, - }; + const LAYOUT = { frameSpacing: 6 }; - // ============================================================================ - // Color Utilities - // ============================================================================ + // Function name to color mapping + const FUNCTION_COLORS = { + main: "#306998", + fibonacci: "#D4A910", + add: "#E65100", + multiply: "#7B1FA2", + calculate: "#D4A910", + }; + const DEFAULT_FUNCTION_COLOR = "#306998"; - function hexToCSS(hex) { - return "#" + hex.toString(16).padStart(6, "0").toUpperCase(); - } + // Easing functions - cubic-bezier approximations + const EASING_MAP = { + linear: "linear", + easeOutQuad: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", + easeOutCubic: "cubic-bezier(0.215, 0.61, 0.355, 1)", + }; function getFunctionColor(funcName) { - // Blue for main, gold for other functions - matching Tachyon logo - if (funcName === "main") return hexToCSS(COLORS.tachyonBlue); - if (funcName === "fibonacci") return hexToCSS(COLORS.tachyonGold); - if (funcName === "add") return "#E65100"; // Orange - if (funcName === "multiply") return "#7B1FA2"; // Purple - if (funcName === "calculate") return hexToCSS(COLORS.tachyonGold); - return hexToCSS(COLORS.tachyonBlue); + return FUNCTION_COLORS[funcName] || DEFAULT_FUNCTION_COLOR; } // ============================================================================ @@ -94,37 +51,17 @@ to(element, props, duration, easing = "easeOutQuad", onComplete = null) { this.killAnimationsOf(element); - // Cubic-bezier approximations of Robert Penner's easing equations. - // See: https://easings.net/ for visual references. - // Format: cubic-bezier(x1, y1, x2, y2) defines control points for the curve. - const easingMap = { - linear: "linear", - easeInQuad: "cubic-bezier(0.55, 0.085, 0.68, 0.53)", - easeOutQuad: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", - easeInOutQuad: "cubic-bezier(0.455, 0.03, 0.515, 0.955)", - easeInCubic: "cubic-bezier(0.55, 0.055, 0.675, 0.19)", - easeOutCubic: "cubic-bezier(0.215, 0.61, 0.355, 1)", - easeInOutCubic: "cubic-bezier(0.645, 0.045, 0.355, 1)", - easeOutElastic: "cubic-bezier(0.68, -0.55, 0.265, 1.55)", - easeOutBack: "cubic-bezier(0.175, 0.885, 0.32, 1.275)", - easeOutBounce: "cubic-bezier(0.68, -0.25, 0.265, 1.25)", - }; - - const cssEasing = easingMap[easing] || easingMap.easeOutQuad; + const cssEasing = EASING_MAP[easing] || EASING_MAP.easeOutQuad; const transformProps = {}; const otherProps = {}; for (const [key, value] of Object.entries(props)) { - if (key === "position" || key === "x" || key === "y") { - if (key === "position") { - if (typeof value.x === "number") transformProps.x = value.x; - if (typeof value.y === "number") transformProps.y = value.y; - } else if (key === "x") { - transformProps.x = value; - } else if (key === "y") { - transformProps.y = value; - } + if (key === "position") { + if (typeof value.x === "number") transformProps.x = value.x; + if (typeof value.y === "number") transformProps.y = value.y; + } else if (key === "x" || key === "y") { + transformProps[key] = value; } else if (key === "scale") { transformProps.scale = value; } else if (key === "alpha" || key === "opacity") { @@ -136,25 +73,24 @@ const computedStyle = getComputedStyle(element); const matrix = new DOMMatrix(computedStyle.transform); + const currentScale = Math.sqrt( + matrix.m11 * matrix.m11 + matrix.m21 * matrix.m21, + ); - if (transformProps.x === undefined) transformProps.x = matrix.m41; - if (transformProps.y === undefined) transformProps.y = matrix.m42; - if (transformProps.scale === undefined) { - transformProps.scale = Math.sqrt( - matrix.m11 * matrix.m11 + matrix.m21 * matrix.m21, - ); - } + transformProps.x ??= matrix.m41; + transformProps.y ??= matrix.m42; + transformProps.scale ??= currentScale; const initialTransform = this._buildTransformString( matrix.m41, matrix.m42, - Math.sqrt(matrix.m11 * matrix.m11 + matrix.m21 * matrix.m21), + currentScale, ); const finalTransform = this._buildTransformString( - transformProps.x !== undefined ? transformProps.x : matrix.m41, - transformProps.y !== undefined ? transformProps.y : matrix.m42, - transformProps.scale !== undefined ? transformProps.scale : 1, + transformProps.x, + transformProps.y, + transformProps.scale, ); const initialKeyframe = { transform: initialTransform }; @@ -337,33 +273,21 @@ } _highlightSyntax(line) { - let highlighted = line + return line .replace(/&/g, "&") .replace(//g, ">"); - highlighted = highlighted.replace( - /(f?"[^"]*"|f?'[^']*')/g, - '$1', - ); - highlighted = highlighted.replace( - /(#.*$)/g, - '$1', - ); - const keywords = - /\b(def|if|elif|else|return|for|in|range|print|__name__|__main__)\b/g; - highlighted = highlighted.replace( - keywords, - '$1', - ); - highlighted = highlighted.replace( - /def<\/span>\s+(\w+)/g, - 'def $1', - ); - highlighted = highlighted.replace( - /\b(\d+)\b/g, - '$1', - ); - return highlighted; + .replace(/>/g, ">") + .replace(/(f?"[^"]*"|f?'[^']*')/g, '$1') + .replace(/(#.*$)/g, '$1') + .replace( + /\b(def|if|elif|else|return|for|in|range|print|__name__|__main__)\b/g, + '$1', + ) + .replace( + /def<\/span>\s+(\w+)/g, + 'def $1', + ) + .replace(/\b(\d+)\b/g, '$1'); } highlightLine(lineNumber) { @@ -390,22 +314,6 @@ } } - _scrollToLine(lineElement) { - const containerRect = this.codeContainer.getBoundingClientRect(); - const lineRect = lineElement.getBoundingClientRect(); - const isAbove = lineRect.top < containerRect.top + 50; - const isBelow = lineRect.bottom > containerRect.bottom - 50; - - if (isAbove || isBelow) { - // Scroll within the container only, not the page - const lineTop = lineElement.offsetTop; - const containerHeight = this.codeContainer.clientHeight; - const targetScroll = - lineTop - containerHeight / 2 + lineElement.offsetHeight / 2; - this.codeContainer.scrollTop = Math.max(0, targetScroll); - } - } - reset() { this.highlightLine(null); this.codeContainer.scrollTop = 0; @@ -432,10 +340,10 @@ this.element.className = "stack-frame"; this.element.dataset.function = functionName; - const bg = document.createElement("div"); - bg.className = "stack-frame-bg"; - bg.style.backgroundColor = this.color; - this.element.appendChild(bg); + this.bgElement = document.createElement("div"); + this.bgElement.className = "stack-frame-bg"; + this.bgElement.style.backgroundColor = this.color; + this.element.appendChild(this.bgElement); this.textElement = document.createElement("span"); this.textElement.className = "stack-frame-text"; @@ -451,9 +359,7 @@ } destroy() { - if (this.element.parentNode) { - this.element.parentNode.removeChild(this.element); - } + this.element.parentNode?.removeChild(this.element); } updateLine(lineno) { @@ -464,18 +370,15 @@ setActive(isActive) { if (this.isActive === isActive) return; this.isActive = isActive; - const bg = this.element.querySelector(".stack-frame-bg"); - bg.style.opacity = isActive ? "1.0" : "0.9"; + this.bgElement.style.opacity = isActive ? "1.0" : "0.9"; } _onHover() { - const bg = this.element.querySelector(".stack-frame-bg"); - bg.style.opacity = "0.8"; + this.bgElement.style.opacity = "0.8"; } _onHoverOut() { - const bg = this.element.querySelector(".stack-frame-bg"); - bg.style.opacity = this.isActive ? "1.0" : "0.9"; + this.bgElement.style.opacity = this.isActive ? "1.0" : "0.9"; } flash(duration = 150) { @@ -960,10 +863,6 @@ this.container.appendChild(this.flashOverlay); } - isAnimating() { - return this.flyingAnimationInProgress; - } - triggerSamplingEffect(stackViz, samplingPanel, currentTime, trace) { if (this.flyingAnimationInProgress) return;