branch: elpa/flamegraph
commit 24ec9e7915446d4cd966dea84cea5aac10ff3e87
Author: Dmitry Gutov <[email protected]>
Commit: Dmitry Gutov <[email protected]>

    Initial commit
    
    Render Emacs profiler data as a flame graph.
---
 profiler-flamegraph.el | 426 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 426 insertions(+)

diff --git a/profiler-flamegraph.el b/profiler-flamegraph.el
new file mode 100644
index 0000000000..20fbd489f3
--- /dev/null
+++ b/profiler-flamegraph.el
@@ -0,0 +1,426 @@
+;;; profiler-flamegraph.el --- Flame graphs for the Emacs profiler  -*- 
lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Dmitry Gutov
+
+;; Author: Dmitry Gutov <[email protected]>
+;; Keywords: lisp, tools
+;; Version: 0.1
+;; Package-Requires: ((emacs "30.1"))
+
+;; This file is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;;; Commentary:
+
+;; Visualize the data recorded by Emacs's native profiler (see
+;; `profiler.el') as a flame graph.
+;;
+;; Run a profiler session as usual (`profiler-start' ... `profiler-stop'),
+;; then `M-x profiler-flamegraph' to view the result.  `M-x
+;; profiler-flamegraph-find-profile' opens a profile previously saved with
+;; `profiler-report-write-profile'.
+;;
+;; The graph is drawn top-down ("icicle" orientation): the outermost frame
+;; (bottom of the call stack) is the top row, and the stack grows downward.
+;; Each box's width is proportional to the number of samples (or bytes, for
+;; memory profiles) collected while that frame was on the stack.
+;;
+;; In the *Flamegraph* buffer:
+;;   RET / mouse-1   zoom into the frame at point (make it the new root)
+;;   u               zoom back out to the parent frame
+;;   n / TAB         move to the next frame
+;;   p / S-TAB       move to the previous frame
+;;   d               describe the function at point
+;;   g               redraw (e.g. after resizing the window)
+;;   q               quit
+;;
+;; The pipeline has three stages:
+;;   1. `profiler-calltree-build' (from profiler.el) merges the recorded
+;;      backtraces into a call tree.
+;;   2. `profiler-flamegraph--frames' lays that tree out into a flat list of
+;;      `profiler-flamegraph-frame' records, each carrying a fractional
+;;      START and WIDTH in [0,1] plus a DEPTH.
+;;   3. The frames are rendered into the buffer, which you can then navigate
+;;      and zoom.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'profiler)
+
+(defgroup profiler-flamegraph nil
+  "Flame graphs for the Emacs profiler."
+  :group 'profiler
+  :prefix "profiler-flamegraph-")
+
+(defcustom profiler-flamegraph-width nil
+  "Width of the flame-graph canvas, in columns.
+If nil, use the width of the window displaying the buffer."
+  :type '(choice (const :tag "Window width" nil) natnum))
+
+(defcustom profiler-flamegraph-frame-border 1
+  "Width in pixels of the gap left on each side of a frame.
+Separates adjacent frames visually.  0 disables it.  On a text
+terminal a \"pixel\" is one column."
+  :type 'natnum)
+
+;;; Layout: call tree -> flat list of frames
+
+(cl-defstruct (profiler-flamegraph-frame
+               (:constructor profiler-flamegraph--frame)
+               (:copier nil))
+  "A single laid-out box of the flame graph.
+NODE is the `profiler-calltree' node it stands for.  START and WIDTH
+give its horizontal extent as fractions in [0,1] of the whole graph,
+and DEPTH is its row (0 is the outermost frame)."
+  node depth start width)
+
+(defun profiler-flamegraph--collect (node depth start total frames)
+  "Lay out calltree NODE and its descendants, prepending to FRAMES.
+DEPTH is NODE's row.  START is its left edge and TOTAL the denominator,
+both in sample/byte counts.  Return (FRAMES . MAX-DEPTH)."
+  (let* ((count (profiler-calltree-count node))
+         (frame (profiler-flamegraph--frame
+                 :node node :depth depth
+                 :start (/ (float start) total)
+                 :width (/ (float count) total)))
+         (max-depth depth)
+         (child-start start))
+    (push frame frames)
+    (dolist (child (profiler-calltree-children node))
+      (let ((res (profiler-flamegraph--collect
+                  child (1+ depth) child-start total frames)))
+        (setq frames (car res)
+              max-depth (max max-depth (cdr res))))
+      (cl-incf child-start (profiler-calltree-count child)))
+    (cons frames max-depth)))
+
+(defun profiler-flamegraph--frames (root)
+  "Lay out the flame graph rooted at ROOT, a `profiler-calltree' node.
+If ROOT is the dummy top node (its entry is nil), its children become
+the depth-0 frames; otherwise ROOT itself is the single depth-0 frame.
+Return a list (FRAMES TOTAL MAX-DEPTH), where TOTAL is the count the
+widths are relative to."
+  (let (frames (max-depth 0) (total 0))
+    (if (profiler-calltree-entry root)
+        ;; Zoomed in: ROOT spans the full width.
+        (progn
+          (setq total (max 1 (profiler-calltree-count root)))
+          (let ((res (profiler-flamegraph--collect root 0 0 total nil)))
+            (setq frames (car res) max-depth (cdr res))))
+      ;; Full view: the dummy root's children are the outermost frames.
+      (setq total (max 1 (apply #'+ (mapcar #'profiler-calltree-count
+                                            (profiler-calltree-children 
root)))))
+      (let ((start 0))
+        (dolist (child (profiler-calltree-children root))
+          (let ((res (profiler-flamegraph--collect child 0 start total 
frames)))
+            (setq frames (car res)
+                  max-depth (max max-depth (cdr res))))
+          (cl-incf start (profiler-calltree-count child)))))
+    (list frames total max-depth)))
+
+;;; Frame appearance
+
+(defun profiler-flamegraph--entry-name (entry)
+  "Return a display name for calltree ENTRY."
+  (cond ((eq entry t) "Others")
+        ((eq entry '...) "...")
+        ((stringp entry) entry)
+        ((symbolp entry) (symbol-name entry))
+        (t (require 'help-fns)
+           (substring-no-properties (help-fns-function-name entry)))))
+
+(defun profiler-flamegraph--color (name)
+  "Return a warm \"hot\" background color for the frame named NAME.
+The same NAME always maps to the same color, as in classic flame graphs."
+  (let ((h (abs (sxhash-equal name))))
+    (format "#%02x%02x%02x"
+            (+ 205 (% h 50))            ; red:   205-254
+            (% (/ h 50) 230)            ; green: 0-229
+            (% (/ h 11500) 55))))       ; blue:  0-54
+
+;;; Text renderer
+
+;; Rows are laid out in pixels using `(space :align-to PIXEL)'.  Because
+;; every frame is positioned at an absolute pixel offset, widths are exact
+;; (not rounded to whole columns) and parent/child edges line up across
+;; rows; a background gap of `profiler-flamegraph-frame-border' pixels on
+;; each side separates adjacent frames.  On a text terminal a "pixel" is one
+;; column, so the same code degrades to a column layout.
+
+(defvar-local profiler-flamegraph--frame-positions nil
+  "Sorted vector of buffer positions, one per drawn frame.")
+
+(defun profiler-flamegraph--canvas-width ()
+  "Return the canvas width in pixels (columns on a text terminal)."
+  (let ((cw (frame-char-width)))
+    (max (* 40 cw)
+         (or (and profiler-flamegraph-width (* profiler-flamegraph-width cw))
+             (let ((win (get-buffer-window (current-buffer) t)))
+               (and win (window-body-width win t)))
+             (* 80 cw)))))
+
+(defun profiler-flamegraph--render-text (frames max-depth grand-total)
+  "Draw FRAMES into the current buffer, one row per depth.
+MAX-DEPTH is the deepest row; GRAND-TOTAL the whole-profile count."
+  (let* ((cw (frame-char-width))
+         (total-px (profiler-flamegraph--canvas-width))
+         (border (if (display-graphic-p) profiler-flamegraph-frame-border 0))
+         (rows (make-vector (1+ max-depth) nil))
+         positions)
+    ;; Bucket frames by row as pixel spans, dropping ones too narrow.
+    (dolist (frame frames)
+      (let ((x0 (round (* (profiler-flamegraph-frame-start frame) total-px)))
+            (x1 (round (* (+ (profiler-flamegraph-frame-start frame)
+                             (profiler-flamegraph-frame-width frame))
+                          total-px))))
+        (when (>= (- x1 x0) 1)          ; skip sub-pixel frames
+          (push (list x0 x1 frame)
+                (aref rows (profiler-flamegraph-frame-depth frame))))))
+    (dotimes (depth (1+ max-depth))
+      (dolist (seg (sort (aref rows depth) :key #'car))
+        (pcase-let* ((`(,x0 ,x1 ,frame) seg)
+                     ;; Inset the colored box by BORDER on each side, unless
+                     ;; the frame is too thin to spare the pixels.
+                     (insetp (>= (- x1 x0) (* 2 (1+ border))))
+                     (boxl (if insetp (+ x0 border) x0))
+                     (boxr (if insetp (- x1 border) x1))
+                     (node (profiler-flamegraph-frame-node frame))
+                     (count (profiler-calltree-count node))
+                     (name (profiler-flamegraph--entry-name
+                            (profiler-calltree-entry node)))
+                     ;; Truncate the name to what fits; no ellipsis.
+                     (label (truncate-string-to-width
+                             name (max 0 (/ (- boxr boxl) cw))))
+                     (props (list 'face (list :background
+                                              (profiler-flamegraph--color name)
+                                              :foreground "black")
+                                  'mouse-face 'highlight
+                                  'profiler-flamegraph-frame frame
+                                  'help-echo
+                                  (format "%s  —  %s (%s of total)"
+                                          name (profiler-format-number count)
+                                          (profiler-format-percent
+                                           count grand-total)))))
+          ;; Empty background gap, up to the box's left edge.
+          (insert (propertize " " 'display `(space :align-to (,boxl))))
+          (push (point) positions)
+          ;; The colored box: the label, then a stretch filling to BOXR.
+          (unless (string-empty-p label)
+            (insert (apply #'propertize label props)))
+          (insert (apply #'propertize " "
+                         'display `(space :align-to (,boxr)) props))))
+      (insert "\n"))
+    (setq profiler-flamegraph--frame-positions
+          (vconcat (sort positions #'<)))))
+
+;;; Buffer state and drawing
+
+(defvar-local profiler-flamegraph--profile nil
+  "The `profiler-profile' being displayed.")
+(defvar-local profiler-flamegraph--top nil
+  "The full call tree (dummy root node from `profiler-calltree-build').")
+(defvar-local profiler-flamegraph--root nil
+  "Currently zoomed-in calltree node, or nil for the full view.")
+(defvar-local profiler-flamegraph--grand-total 0
+  "Total sample/byte count of the whole profile.")
+
+(defun profiler-flamegraph--unit ()
+  "Return the count unit of the current profile, as a string."
+  (if (eq (profiler-profile-type profiler-flamegraph--profile) 'memory)
+      "bytes" "samples"))
+
+(defun profiler-flamegraph--header ()
+  "Return the header line for the current view."
+  (let* ((type (profiler-profile-type profiler-flamegraph--profile))
+         (root profiler-flamegraph--root)
+         (zoomed (and root (profiler-calltree-entry root))))
+    (format " %s flame graph · %s %s · root: %s%s"
+            (if (eq type 'cpu) "CPU" "Memory")
+            (profiler-format-number profiler-flamegraph--grand-total)
+            (profiler-flamegraph--unit)
+            (if zoomed
+                (profiler-flamegraph--entry-name (profiler-calltree-entry 
root))
+              "all")
+            (if zoomed
+                (format " (%s)" (profiler-format-percent
+                                 (profiler-calltree-count root)
+                                 profiler-flamegraph--grand-total))
+              ""))))
+
+(defun profiler-flamegraph--draw ()
+  "(Re)draw the flame graph in the current buffer."
+  (let ((inhibit-read-only t)
+        (root (or profiler-flamegraph--root profiler-flamegraph--top)))
+    (erase-buffer)
+    (pcase-let ((`(,frames ,_total ,max-depth)
+                 (profiler-flamegraph--frames root)))
+      (profiler-flamegraph--render-text
+       frames max-depth profiler-flamegraph--grand-total))
+    ;; Double % so percentages aren't taken as mode-line %-constructs.
+    (setq header-line-format
+          (string-replace "%" "%%" (profiler-flamegraph--header)))
+    ;; Each row begins with a background gap, so land on the first frame.
+    (goto-char (if (length> profiler-flamegraph--frame-positions 0)
+                   (aref profiler-flamegraph--frame-positions 0)
+                 (point-min)))))
+
+;;; Commands
+
+(defun profiler-flamegraph--frame-at-point ()
+  "Return the `profiler-flamegraph-frame' at point, or nil."
+  (get-text-property (point) 'profiler-flamegraph-frame))
+
+(defun profiler-flamegraph-zoom (&optional event)
+  "Zoom into the frame at point (or at EVENT), making it the new root."
+  (interactive (list last-nonmenu-event))
+  (when (and event (not (eq event 'return)))
+    (let ((posn (event-end event)))
+      (when (posnp posn) (posn-set-point posn))))
+  (let ((frame (profiler-flamegraph--frame-at-point)))
+    (when frame
+      (setq profiler-flamegraph--root (profiler-flamegraph-frame-node frame))
+      (profiler-flamegraph--draw))))
+
+(defun profiler-flamegraph-zoom-out ()
+  "Zoom out to the parent of the current root frame."
+  (interactive)
+  (let* ((root profiler-flamegraph--root)
+         (parent (and root (profiler-calltree-parent root))))
+    (if (null root)
+        (message "Already at the top level")
+      ;; A parent whose entry is nil is the dummy top: go to the full view.
+      (setq profiler-flamegraph--root
+            (and parent (profiler-calltree-entry parent) parent))
+      (profiler-flamegraph--draw))))
+
+(defun profiler-flamegraph--goto (next)
+  "Move point to the next drawn frame, or the previous one if NEXT is nil."
+  (let ((pt (point)) (best nil))
+    (cl-loop for p across profiler-flamegraph--frame-positions do
+             (when (if next
+                       (and (> p pt) (or (null best) (< p best)))
+                     (and (< p pt) (or (null best) (> p best))))
+               (setq best p)))
+    (if best
+        (goto-char best)
+      (message "No %s frame" (if next "next" "previous")))))
+
+(defun profiler-flamegraph-next ()
+  "Move point to the next frame."
+  (interactive)
+  (profiler-flamegraph--goto t))
+
+(defun profiler-flamegraph-previous ()
+  "Move point to the previous frame."
+  (interactive)
+  (profiler-flamegraph--goto nil))
+
+(defun profiler-flamegraph-describe ()
+  "Describe the function of the frame at point."
+  (interactive)
+  (let ((frame (profiler-flamegraph--frame-at-point)))
+    (if (null frame)
+        (message "No frame at point")
+      (let ((entry (profiler-calltree-entry
+                    (profiler-flamegraph-frame-node frame))))
+        (if (or (and (symbolp entry) (fboundp entry)) (functionp entry))
+            (progn (require 'help-fns) (describe-function entry))
+          (message "Not a function: %s"
+                   (profiler-flamegraph--entry-name entry)))))))
+
+(defun profiler-flamegraph-redraw ()
+  "Redraw the flame graph, e.g. to pick up a new window width."
+  (interactive)
+  (profiler-flamegraph--draw))
+
+(defun profiler-flamegraph--echo ()
+  "Show information about the frame at point in the echo area."
+  (let ((frame (profiler-flamegraph--frame-at-point)))
+    (when frame
+      (let* ((node (profiler-flamegraph-frame-node frame))
+             (count (profiler-calltree-count node)))
+        (message "%s  %s %s  (%s of total)"
+                 (profiler-flamegraph--entry-name (profiler-calltree-entry 
node))
+                 (profiler-format-number count)
+                 (profiler-flamegraph--unit)
+                 (profiler-format-percent count 
profiler-flamegraph--grand-total))))))
+
+;;; Mode
+
+(defvar-keymap profiler-flamegraph-mode-map
+  :doc "Keymap for `profiler-flamegraph-mode'."
+  "RET"       #'profiler-flamegraph-zoom
+  "<mouse-1>" #'profiler-flamegraph-zoom
+  "u"         #'profiler-flamegraph-zoom-out
+  "n"         #'profiler-flamegraph-next
+  "TAB"       #'profiler-flamegraph-next
+  "p"         #'profiler-flamegraph-previous
+  "<backtab>" #'profiler-flamegraph-previous
+  "d"         #'profiler-flamegraph-describe
+  "g"         #'profiler-flamegraph-redraw
+  "q"         #'quit-window)
+
+(define-derived-mode profiler-flamegraph-mode special-mode "Flamegraph"
+  "Major mode for viewing a flame graph of profiler data."
+  (setq buffer-read-only t
+        truncate-lines t
+        buffer-undo-list t)
+  (add-hook 'post-command-hook #'profiler-flamegraph--echo nil t))
+
+;;; Entry points
+
+(defun profiler-flamegraph--setup-buffer (profile)
+  "Create and return a flame-graph buffer for PROFILE.
+The buffer's state is initialized but it is not drawn; draw it with
+`profiler-flamegraph--draw' once it is shown in a window, so the canvas
+matches the window width."
+  (let ((buffer (get-buffer-create
+                 (format "*Flamegraph: %s*"
+                         (if (eq (profiler-profile-type profile) 'cpu)
+                             "CPU" "Memory")))))
+    (with-current-buffer buffer
+      (profiler-flamegraph-mode)
+      (let ((top (profiler-calltree-build (profiler-profile-log profile))))
+        (profiler-calltree-sort top #'profiler-calltree-count>)
+        (setq profiler-flamegraph--profile profile
+              profiler-flamegraph--top top
+              profiler-flamegraph--root nil
+              profiler-flamegraph--grand-total
+              (max 1 (apply #'+ (mapcar #'profiler-calltree-count
+                                        (profiler-calltree-children top)))))))
+    buffer))
+
+;;;###autoload
+(defun profiler-flamegraph-profile (profile)
+  "Display a flame graph for PROFILE, a `profiler-profile' object."
+  (let ((buffer (profiler-flamegraph--setup-buffer profile)))
+    ;; Display first, then draw, so the canvas spans the actual window.
+    (pop-to-buffer buffer)
+    (profiler-flamegraph--draw)))
+
+;;;###autoload
+(defun profiler-flamegraph ()
+  "Display a flame graph of the current profiler results.
+Uses the CPU profile if one was recorded, otherwise the memory profile."
+  (interactive)
+  (when (and (fboundp 'profiler-cpu-running-p) (profiler-cpu-running-p))
+    (setq profiler-cpu-log (profiler-cpu-log)))
+  (when (profiler-memory-running-p)
+    (setq profiler-memory-log (profiler-memory-log)))
+  (cond (profiler-cpu-log
+         (profiler-flamegraph-profile (profiler-cpu-profile)))
+        (profiler-memory-log
+         (profiler-flamegraph-profile (profiler-memory-profile)))
+        (t (user-error "No profiler run recorded"))))
+
+;;;###autoload
+(defun profiler-flamegraph-find-profile (filename)
+  "Display a flame graph for the profile saved in FILENAME."
+  (interactive (list (read-file-name "Find profile: " default-directory)))
+  (profiler-flamegraph-profile (profiler-read-profile filename)))
+
+(provide 'profiler-flamegraph)
+;;; profiler-flamegraph.el ends here

Reply via email to