Skip to content

Commit c5a5e9c

Browse files
[profile] Port thunknyc/profile to Orchard
1 parent e8924e6 commit c5a5e9c

File tree

4 files changed

+284
-4
lines changed

4 files changed

+284
-4
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## master (unreleased)
44

5+
* [#333](https://github.com/clojure-emacs/orchard/pull/333): Add `orchard.profile`.
6+
57
## 0.32.1 (2025-04-05)
68

79
* [#328](https://github.com/clojure-emacs/orchard/pull/328): Inspector: display identity hashcode for Java objects.

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ Right now `orchard` provides functionality like:
1515
* classpath utils (alternative for `java.classpath`)
1616
* value [inspector](https://github.com/clojure-emacs/orchard/blob/master/doc/inspector.org)
1717
* Java class handling utilities
18-
* Utilities for dealing with metadata
19-
* Namespace utilities
20-
* Fetching ClojureDocs documentation
21-
* Finding function dependencies (other functions invoked by a function) and usages
18+
* utilities for dealing with metadata
19+
* namespace utilities
20+
* fetching ClojureDocs documentation
21+
* finding function dependencies (other functions invoked by a function) and usages
22+
* function tracer
23+
* simple function profiler
2224

2325
## Why?
2426

src/orchard/profile.clj

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
(ns orchard.profile
2+
"Very simplistic manual tracing profiler for individual functions."
3+
{:author "Oleksandr Yakushev"
4+
:added "0.33"}
5+
(:require [orchard.misc :as misc])
6+
(:import java.util.concurrent.locks.ReentrantLock
7+
java.util.Arrays))
8+
9+
;; The profiler works like following: for each profiled function, an entry in
10+
;; `collected-timings` atom is created. Timings are stored as an array. Inside
11+
;; each array, the first cell stores how many samples we have accumulated so
12+
;; far. When the array becomes full, we grow it 2x until `max-sample-count` is
13+
;; reached. At that point, new sample just overwrites a random old sample. The
14+
;; mutable arrays are protected by a global `data-lock`.
15+
16+
(def ^:private ^:const max-sample-count (int (Math/pow 2 17)))
17+
(def ^:private data-lock (ReentrantLock.))
18+
(def ^:private collected-timings (atom {}))
19+
20+
(defn- assoc-and-get-array [k array]
21+
(get (swap! collected-timings assoc k array) k))
22+
23+
(defn- record-timing [k, ^long nanos]
24+
(misc/with-lock data-lock
25+
(let [^longs arr (or (get @collected-timings k)
26+
;; Initial array is 256 items long (1KB).
27+
(assoc-and-get-array k (long-array 256)))
28+
alen (alength arr)
29+
n (aget arr 0) ;; First cell array stores number of samples.
30+
i (inc n)
31+
;; Check if we've run out of free space in the array and still under
32+
;; the max-sample-count. If so, grow the array.
33+
^longs arr (if (and (>= i alen) (< alen max-sample-count))
34+
(assoc-and-get-array k (Arrays/copyOf arr (* alen 2)))
35+
arr)
36+
alen (alength arr)]
37+
(aset arr 0 i)
38+
(if (< i alen)
39+
(aset arr i nanos)
40+
;; We're out of space and the array can't grow anymore, so we just write
41+
;; to a random position.
42+
(aset arr (inc (rand-int (dec alen))) nanos)))))
43+
44+
(defn- resolve-var ^clojure.lang.Var [v]
45+
(if (var? v) v (resolve v)))
46+
47+
(defn- wrap-profiled [var raw-fn]
48+
(fn profiling-wrapper [& args]
49+
(let [nano-now (System/nanoTime)
50+
val (apply raw-fn args)
51+
elapsed (- (System/nanoTime) nano-now)]
52+
(record-timing var elapsed)
53+
val)))
54+
55+
;;;; Calculations
56+
57+
(defn- standard-deviation [^longs arr, ^double mean]
58+
(let [sum (areduce arr i sum 0.0 (+ sum (Math/pow (- mean (aget arr i)) 2.0)))]
59+
(Math/sqrt (/ sum (max (dec (alength arr)) 1)))))
60+
61+
(defn- entry-stats [var, ^longs samples]
62+
(let [count (aget samples 0)
63+
n (min (dec (alength samples)) count)
64+
sorted (doto (Arrays/copyOfRange samples 1 (inc n)) Arrays/sort)
65+
sum (areduce sorted i sum 0 (+ sum (aget sorted i)))
66+
mean (double (/ sum n))]
67+
(array-map ;; Using array-map to enforce key order.
68+
:name var
69+
:n count
70+
:mean mean
71+
:std (standard-deviation sorted mean)
72+
:sum sum
73+
:min (aget sorted 0)
74+
:max (aget sorted (dec n))
75+
:med (aget sorted (int (/ n 2)))
76+
:samples (vec sorted))))
77+
78+
(defn- format-duration [nanos]
79+
(cond (> nanos 1e9) (format "%.1f s" (/ nanos 1e9))
80+
(> nanos 1e6) (format "%.0f ms" (/ nanos 1e6))
81+
(> nanos 1e3) (format "%.0f us" (/ nanos 1e3))
82+
:else (format "%.0f ns" (double nanos))))
83+
84+
(defn- format-stats-for-inspector [stats-map]
85+
;; Prettify results: attach units to timings, convert strings to symbols to
86+
;; avoid quotes when this data will be displayed in the inspector.
87+
(-> (reduce #(update %1 %2 (comp symbol format-duration)) stats-map
88+
[:mean :sum :min :max :med])
89+
(update :std #(symbol (str "±" (format-duration %))))))
90+
91+
;;;; Public API
92+
93+
(def ^:private profiled-vars (atom #{}))
94+
(def ^:private profiled-nses (atom #{}))
95+
96+
(defn profilable?
97+
"Return true if `v` contains a profilable function."
98+
[v]
99+
(let [v (resolve-var v)]
100+
(and (ifn? @v) (not (:macro (meta v))))))
101+
102+
(defn profiled?
103+
"Return true if `v` is already profiled."
104+
[v]
105+
(let [v (resolve-var v)]
106+
(contains? (meta v) ::profiled)))
107+
108+
(defn profile-var
109+
"If the specified Var holds a function, its contents is replaced with a version
110+
wrapped in a profiling call. Can be undone with `unprofile-var`."
111+
[v]
112+
(let [v (resolve-var v)]
113+
(when (and (profilable? v) (not (profiled? v)))
114+
(let [raw-fn @v]
115+
(swap! profiled-vars conj v)
116+
(alter-var-root v #(wrap-profiled v %))
117+
(alter-meta! v assoc ::profiled raw-fn)
118+
v))))
119+
120+
(defn unprofile-var
121+
"Reverses the effect of `profile-var` for the given Var, replacing the profiled
122+
function with the original version."
123+
[v]
124+
(let [v (resolve-var v)
125+
f (::profiled (meta v))]
126+
(when f
127+
(alter-var-root v (constantly (::profiled (meta v))))
128+
(alter-meta! v dissoc ::profiled)
129+
(swap! profiled-vars disj v)
130+
v)))
131+
132+
(defn profile-ns
133+
"Profile all Vars in the given namespace. Can be undone with `unprofile-ns`."
134+
[ns]
135+
(let [ns (the-ns ns)]
136+
(when-not ('#{clojure.core orchard.profile} (.name ns))
137+
(->> (ns-interns ns)
138+
vals
139+
(filter (comp fn? var-get))
140+
(run! profile-var))
141+
(swap! profiled-nses conj ns))))
142+
143+
(defn unprofile-ns
144+
"Unprofile all Vars in the given namespace."
145+
[ns]
146+
(let [ns (the-ns ns)]
147+
(->> (ns-interns ns)
148+
vals
149+
(filter (comp fn? var-get))
150+
(run! unprofile-var))
151+
(swap! profiled-nses disj ns)))
152+
153+
(defn toggle-profile-ns
154+
"Profile vars in the given namespace if it's not profiled yet, otherwise undo
155+
the profiling. Return true if profiling did happen."
156+
[ns]
157+
(let [ns (the-ns ns)]
158+
(if (contains? @profiled-nses ns)
159+
(do (unprofile-ns ns)
160+
false)
161+
(do (profile-ns ns)
162+
true))))
163+
164+
(defn unprofile-all
165+
"Reverses the effect of profiling for all already profiled vars and namespaces."
166+
[]
167+
(run! unprofile-ns @profiled-nses)
168+
(run! unprofile-var @profiled-vars))
169+
170+
(defn summary
171+
"Returns a map where keys are the profiled function vars, and values are maps
172+
with the profiling stats."
173+
[]
174+
(misc/with-lock data-lock
175+
(into {} (map (fn [[var samples]] [var (entry-stats var samples)]))
176+
@collected-timings)))
177+
178+
(defn summary-for-inspector
179+
"Return profiling results as a list of stats maps, optimized to be viewed with
180+
`orchard.inspect`."
181+
[]
182+
(sort-by #(str (:name %)) (vals (misc/update-vals format-stats-for-inspector (summary)))))
183+
184+
(defn clear
185+
"Clears all profiling results."
186+
[]
187+
(misc/with-lock data-lock
188+
(reset! collected-timings {})))

test/orchard/profile_test.clj

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
(ns orchard.profile-test
2+
(:require
3+
[clojure.test :as t :refer [deftest testing]]
4+
[matcher-combinators.matchers :as matchers]
5+
[orchard.profile :as sut]
6+
[orchard.test.util :refer [is+]]
7+
[orchard.trace-test.sample-ns :as sample-ns]))
8+
9+
(defn- run-fns []
10+
(dotimes [_ 10] (sample-ns/qux "abc" "efg")))
11+
12+
(deftest basic-profiling-test
13+
(sut/clear)
14+
(sut/profile-ns 'orchard.trace-test.sample-ns)
15+
(run-fns)
16+
17+
(testing "summary returns profiling results for all vars"
18+
(is+ {#'sample-ns/baz {:name #'sample-ns/baz
19+
:n 10
20+
:mean number?
21+
:std number?
22+
:sum number?
23+
:min number?
24+
:max number?
25+
:med number?
26+
:samples vector?}
27+
#'sample-ns/bar {:name #'sample-ns/bar
28+
:n 10
29+
:mean number?
30+
:std number?
31+
:sum number?
32+
:min number?
33+
:max number?
34+
:med number?
35+
:samples vector?}
36+
#'sample-ns/foo map?
37+
#'sample-ns/qux map?}
38+
(sut/summary)))
39+
40+
(sut/clear)
41+
(sut/unprofile-var #'sample-ns/foo)
42+
(sut/unprofile-var #'sample-ns/qux)
43+
(run-fns)
44+
45+
(testing "only two vars are profiled now"
46+
(is+ {#'sample-ns/baz map?
47+
#'sample-ns/bar map?
48+
#'sample-ns/foo matchers/absent
49+
#'sample-ns/qux matchers/absent}
50+
(sut/summary)))
51+
52+
(sut/clear)
53+
(sut/unprofile-var #'sample-ns/bar)
54+
(sut/unprofile-var #'sample-ns/baz)
55+
(run-fns)
56+
(testing "no vars are profiled now"
57+
(is+ empty? (sut/summary)))
58+
59+
(sut/profile-ns 'orchard.trace-test.sample-ns)
60+
(sut/unprofile-ns 'orchard.trace-test.sample-ns)
61+
(run-fns)
62+
(testing "turning namespace profiling on and then off leaves no vars profiled"
63+
(is+ empty? (sut/summary))))
64+
65+
(deftest too-many-samples-test
66+
(sut/clear)
67+
(sut/profile-ns 'orchard.trace-test.sample-ns)
68+
(dotimes [_ 1e6] (sample-ns/qux "abc" "efg"))
69+
(sut/summary)
70+
(testing "overflow samples are still counted"
71+
(is+ 1000000 (:n (get (sut/summary) #'sample-ns/qux)))))
72+
73+
(deftest summary-for-inspector-test
74+
(sut/clear)
75+
(sut/profile-ns 'orchard.trace-test.sample-ns)
76+
(run-fns)
77+
(is+ [{:name #'sample-ns/bar
78+
:n 10
79+
:mean (matchers/via str #" [num]?s$")
80+
:std (matchers/via str #"^±.+ [num]?s$")
81+
:sum (matchers/via str #" [num]?s$")
82+
:min (matchers/via str #" [num]?s$")
83+
:max (matchers/via str #" [num]?s$")
84+
:med (matchers/via str #" [num]?s$")}
85+
{:name #'sample-ns/baz, :n 10}
86+
{:name #'sample-ns/foo, :n 10}
87+
{:name #'sample-ns/qux, :n 10}]
88+
(sut/summary-for-inspector)))

0 commit comments

Comments
 (0)