Skip to content

Instantly share code, notes, and snippets.

@whilo
Last active August 16, 2016 21:06
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save whilo/a8ef2cd3f0e033d3973880a2001be32a to your computer and use it in GitHub Desktop.
Save whilo/a8ef2cd3f0e033d3973880a2001be32a to your computer and use it in GitHub Desktop.
(ns full.binding-test
#?(:cljs (:require [cljs.core.async :refer [<! chan onto-chan]]
[zones.core :as zones :include-macros true]))
#?(:cljs (:require-macros
[full.async :refer [<<!]]
[full.binding-test :refer
[new-bound-fn create-vars new-binding]]
[cljs.core.async.macros :refer [go go-loop]])))
#?(:cljs (enable-console-print!))
;; Binding approach:
;; We only track the active bindings which are currently rebound
;; through the binding macro (could do it for with-redefs as well). We
;; do reference counting here to do minimal work. The cost is only paid
;; by the user of binding and the async mechanisms supporting binding.
;; Explicit opt-out of the tracking mechanism using the old binding
;; would also be possible.
#?(:cljs
(def active-bindings #js {}))
#?(:cljs
(defn resolve-frame [active-bindings]
(let [res #js []
ks (js/Object.keys active-bindings)]
(loop [i 0]
(when (< i (alength ks))
(let [b (aget active-bindings (aget ks i))]
(.push res #js [(aget b "setter")
((aget b "getter"))]))
(recur (inc i))))
res)))
(defn restore [res-frame]
(loop [i 0]
(when (< i (alength res-frame))
((aget (aget res-frame i) 0)
(aget (aget res-frame i) 1))
(recur (inc i)))))
#?(:cljs
(defn inc-binding [sym setter getter]
(if-let [b (aget active-bindings sym)]
(aset b "count" (inc (aget b "count")))
(aset active-bindings sym
#js {"count" 0
"setter" setter
"getter" getter}))))
(comment
(inc-binding 'foo inc dec)
(macroexpand-1 '(inc-binding foo 42)))
#?(:cljs
(defn dec-binding [sym]
(let [b (aget active-bindings sym)
c (aget b "count")]
(if (pos? c)
(aset b "count" (dec c))
(js-delete active-bindings sym)))))
(comment
(dec-binding 'foo))
#?(:clj
(defmacro new-binding [bindings & body]
(let [names (take-nth 2 bindings)
vals (take-nth 2 (drop 1 bindings))
tempnames (map (comp gensym name) names)
binds (map vector names vals)
resets (reverse (map vector names tempnames))
bind-value (fn [[k v]]
;; TODO fix fully qualified namespaced name
(let [sym# (str (:name (:ns &env)) "/" (pr-str k))]
(list 'do
`(inc-binding ~sym# (fn [v#] (set! ~k v#)) (fn [] ~k))
(list 'set! k v)
)))
unbind-value (fn [[k v]]
;; TODO fix fully qualified namespaced name
(let [sym# (str (:name (:ns &env)) "/" (pr-str k))]
(list 'do
`(dec-binding ~sym#)
(list 'set! k v)
)))]
`(let [~@(interleave tempnames names)]
~@(map bind-value binds)
(try
~@body
(finally
~@(map unbind-value resets)))))))
(comment
(def ^:dynamic foo 1)
(do
(println active-bindings)
(new-binding [foo 42]
(println active-bindings)
foo)
(println active-bindings)))
;; helper
#?(:clj
(defmacro create-vars [n]
`(do
~@(map (fn [n#] `(def ~(vary-meta (symbol (str "v" n#)) assoc :dynamic true) ~n#))
(range n)))))
#?(:cljs
(defn ^:export benchmark1 []
(create-vars 21)
(def res-frame (resolve-frame active-bindings))
(simple-benchmark [] (restore res-frame) 10000)))
#?(:cljs
(defn ^:export benchmark2 []
;; do a quick, not particularly smart analysis of impact of this binding for core.async
;; when no bindings are active.
(go (simple-benchmark [] (<! (go 42)) 10000)) ;; ~200 ms (Chromium)
))
#?(:cljs
(defn ^:export benchmark3 []
;; there is a little overhead (~10 %) caused by tracking active-bindings
(go (simple-benchmark []
(let [frame (resolve-frame active-bindings)]
;; ...
;; async scope
(let [old (resolve-frame active-bindings)]
(restore frame)
(<! (go 42))
(restore old))) 10000)) ;; ~220 ms (Chromium)
))
#?(:cljs
(defn ^:export benchmark4 []
;; an empty binding itself causes some overhead (the same for current binding macro)
(go (simple-benchmark []
(new-binding []
(let [frame (resolve-frame active-bindings)]
;; ...
;; async scope
(let [old (resolve-frame active-bindings)]
(restore frame)
(<! (go 42))
(restore old)))) 10000)) ;; ~300 ms (Chromium)
))
#?(:cljs
(defn ^:export benchmark5 []
;; now the user of binding pays roughly linear cost for every
;; binding that is active
(go (simple-benchmark []
(new-binding [v1 1 v2 2 v3 3 v4 4 v5 5]
(let [frame (resolve-frame active-bindings)]
;; ...
;; async scope
(let [old (resolve-frame active-bindings)]
(restore frame)
(<! (go 42))
(restore old)))) 10000)) ;; ~800 ms (Chromium)
))
#?(:cljs
(defn ^:export benchmark6 []
(go (simple-benchmark []
(new-binding [v1 1 v2 2 v3 3 v4 4 v5 5 v6 6 v7 7 v8 8 v9 9 v10 10]
(let [frame (resolve-frame active-bindings)]
;; ...
;; async scope
(let [old (resolve-frame active-bindings)]
(restore frame)
(<! (go 42))
#_((fn [a] (+ v1 v2 a)) 4)
(restore old)))) 10000)
#_(simple-benchmark []
(zones/binding [v1 1 v2 2 v3 3 v4 4 v5 5 v6 6 v7 7 v8 8 v9 9 v10 10]
((zones/bound-fn []
((fn [a] (+ v1 v2 a)) 4)))) 10000)) ;; ~1500 ms (Chromium)
))
#?(:cljs
(defn ^:export benchmark7 []
(go (simple-benchmark []
(new-binding [v1 1 v2 2 v3 3 v4 4 v5 5 v6 6 v7 7 v8 8 v9 9 v10 10
v11 11 v12 12 v13 13 v14 14 v15 15 v16 16 v17 17 v18 18 v19 19 v20 20]
(let [frame (resolve-frame active-bindings)]
;; ...
;; async scope
(let [old (resolve-frame active-bindings)]
(restore frame)
(<! (go 42))
(restore old)))) 10000)) ;; ~4200 ms (Chromium)
))
;; cost to establish bindings
#?(:cljs
(defn ^:export benchmark8 []
(simple-benchmark []
(binding [v1 1]
;; for a binding form we at least have to call one
;; function for it to make sense
((fn [foo] foo) v1)) 10000) ;; ~2 ms (Chromium)
(simple-benchmark []
(new-binding [v1 1]
((fn [foo] foo) v1)) 10000) ;; ~60 ms (Chromium)
(simple-benchmark []
(zones/binding [v1 1]
((fn [foo] foo) (zones/get v1))) 10000) ;; ~60 ms (Chromium)
))
#?(:clj
(defmacro new-bound-fn
"Now we can implement bound-fn accordingly."
[args & body]
`(let [frame# (resolve-frame active-bindings)]
(fn ~args
(let [old# (resolve-frame active-bindings)]
(restore frame#)
~@body
(restore old#))))))
;; cost to manage bindings
#?(:cljs (defn ^:export benchmark9 []
(simple-benchmark []
(new-binding [v1 1]
((new-bound-fn []
((fn [foo] (+ v1 foo)) v1)))) 10000) ;; ~60 ms (Chromium)
(simple-benchmark []
(zones/binding [v1 1]
((zones/bound-fn []
((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))) 10000)
(simple-benchmark []
(new-binding [v1 1]
((new-bound-fn []
((new-bound-fn []
((fn [foo] (+ v1 foo)) v1)))))) 10000) ;; ~60 ms (Chromium)
(simple-benchmark []
(zones/binding [v1 1]
((zones/bound-fn []
((zones/bound-fn []
((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))))) 10000)
(simple-benchmark []
(new-binding [v1 1]
((new-bound-fn []
((new-bound-fn []
((new-bound-fn []
((fn [foo] (+ v1 foo)) v1)))))))) 10000) ;; ~60 ms (Chromium)
(simple-benchmark []
(zones/binding [v1 1]
((zones/bound-fn []
((zones/bound-fn []
((zones/bound-fn []
((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))))))) 10000)
))
#?(:cljs (defn ^:export benchmark10 []
(simple-benchmark [] ((fn [] v1)) 10000)
(simple-benchmark [] ((fn [] (zones/get v1))) 10000)))
;; some examples runs
;; full.binding_test.benchmark9()
;; core.cljs:150 [], (new-binding [v1 1] ((new-bound-fn [] ((fn [foo] (+ v1 foo)) v1)))), 10000 runs, 44 msecs
;; core.cljs:150 [], (zones/binding [v1 1] ((zones/bound-fn [] ((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))), 10000 runs, 37 msecs
;; core.cljs:150 [], (new-binding [v1 1] ((new-bound-fn [] ((new-bound-fn [] ((fn [foo] (+ v1 foo)) v1)))))), 10000 runs, 58 msecs
;; core.cljs:150 [], (zones/binding [v1 1] ((zones/bound-fn [] ((zones/bound-fn [] ((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))))), 10000 runs, 94 msecs
;; core.cljs:150 [], (new-binding [v1 1] ((new-bound-fn [] ((new-bound-fn [] ((new-bound-fn [] ((fn [foo] (+ v1 foo)) v1)))))))), 10000 runs, 90 msecs
;; core.cljs:150 [], (zones/binding [v1 1] ((zones/bound-fn [] ((zones/bound-fn [] ((zones/bound-fn [] ((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))))))), 10000 runs, 88 msecs
;; full.binding_test.benchmark9()
;; core.cljs:150 [], (new-binding [v1 1] ((new-bound-fn [] ((fn [foo] (+ v1 foo)) v1)))), 10000 runs, 61 msecs
;; core.cljs:150 [], (zones/binding [v1 1] ((zones/bound-fn [] ((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))), 10000 runs, 29 msecs
;; core.cljs:150 [], (new-binding [v1 1] ((new-bound-fn [] ((new-bound-fn [] ((fn [foo] (+ v1 foo)) v1)))))), 10000 runs, 64 msecs
;; core.cljs:150 [], (zones/binding [v1 1] ((zones/bound-fn [] ((zones/bound-fn [] ((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))))), 10000 runs, 49 msecs
;; core.cljs:150 [], (new-binding [v1 1] ((new-bound-fn [] ((new-bound-fn [] ((new-bound-fn [] ((fn [foo] (+ v1 foo)) v1)))))))), 10000 runs, 92 msecs
;; core.cljs:150 [], (zones/binding [v1 1] ((zones/bound-fn [] ((zones/bound-fn [] ((zones/bound-fn [] ((fn [foo] (+ (zones/get v1) foo)) (zones/get v1))))))))), 10000 runs, 84 msecs
;; full.binding_test.benchmark10()
;; core.cljs:150 [], ((fn [] v1)), 10000 runs, 1 msecs
;; core.cljs:150 [], ((fn [] (zones/get v1))), 10000 runs, 4 msecs
;; null
;; full.binding_test.benchmark10()
;; core.cljs:150 [], ((fn [] v1)), 10000 runs, 2 msecs
;; core.cljs:150 [], ((fn [] (zones/get v1))), 10000 runs, 6 msecs
;; null
;; full.binding_test.benchmark10()
;; core.cljs:150 [], ((fn [] v1)), 10000 runs, 3 msecs
;; core.cljs:150 [], ((fn [] (zones/get v1))), 10000 runs, 4 msecs
#?(:cljs
(defn ^:export main []
(do
(def ^:dynamic v1 1)
(println "init" v1)
(new-binding [v1 5]
(println "bound" v1)
(js/setTimeout (new-bound-fn []
(println "init async" v1)
(binding [v1 42]
(println "rebound async" v1))
(println "end async" v1))
1000))
(println "end" v1))))
;; full.binding_test.benchmark10()
;; core.cljs:150 [], ((fn [] v1)), 10000 runs, 1 msecs
;; core.cljs:150 [], ((fn [] (zones/get v1))), 10000 runs, 4 msecs
;; null
;; full.binding_test.benchmark10()
;; core.cljs:150 [], ((fn [] v1)), 10000 runs, 2 msecs
;; core.cljs:150 [], ((fn [] (zones/get v1))), 10000 runs, 6 msecs
;; null
;; full.binding_test.benchmark10()
;; core.cljs:150 [], ((fn [] v1)), 10000 runs, 3 msecs
;; core.cljs:150 [], ((fn [] (zones/get v1))), 10000 runs, 4 msecs
(comment
(benchmark1))
@whilo
Copy link
Author

whilo commented Apr 6, 2016

Is this an approach worth pursuing or is it ugly/non-performant? If there are many dynamic vars core.async performance might suffer if setting all these vars is not cheap in js ... I guess there are not more than a hundred dynamic vars for most projects.

@whilo
Copy link
Author

whilo commented Apr 15, 2016

@swannodette
I have used alength as suggested by @brandonbloom and tried advanced compilation. Advanced compilation works fine with the above examples. The code runs a bit faster then, but in some cases the compiler probably does constant propagation etc. so I have only supplied the numbers for running the benchmarks in the dev REPL.

@darwin
Copy link

darwin commented Apr 28, 2016

Interesting proposal. Just recently I was scratching my head how to achieve "dynamic binding" which would survive async calls and allow me to pass "implicit parameters" safely into functions "below me".

Myself coming from JS land and unaware of binding macro implementation (doing non-trivial work by storing and restoring frames), I was thinking about some effective way how to do this in javascript. A gist of it is described below. The main idea was to (ab)use currently unused this pointer to automatically pass a dynamic context into all function calls (even binding them across closures). We could then use JavaScript prototypal inheritance to chain frames. Dynamic lookups would be done explicitly using a wrapper macro and would boil down to a single native object lookup (javascript prototypal chain would do the dynamic resolution for us natively).

I'm not sure if something like this could fly. Didn't think about it much deeper. It would definitely not work across JS interop boundaries, but there it is usually not needed or can be managed manually. Anyways I'm adding it as a thought experiment.

PSEUDOCODE:

(fn f []
  (dynamic-binding* [v 1]
    (print (dynamic-lookup* v))
    (js/setTimeout (fn [] (print (dynamic-lookup* v))) 1000)))

would compile down to something like this:

f = (function() {
var bindingFrame = createFrame.call(this, {"v-sym": 1}); // emitted by dynamic-binding* macro
(function(){ // this wrapper function was emitted by dynamic-binding* macro
  cljs.core.print.call(this, dynamicLookup.call(this, "v-sym"));
  setTimeout((function() {cljs.core.print.call(this, dynamicLookup.call(this, "v-sym"))}).bind(this), 1000);
}).call(bindingFrame);
});

function dynamicLookup(symbol) {
  return this[symbol];
};

function createFrame(bag) {
  return Object.create(this, bag); // this is how we build the "inheritance" chain
};

From #cljs-dev slack channel on April 17th, 2016

darwin: Today I had this wild idea: assume :static-fns false for now, instead of passing null into all emitted .call invocations, we would pass this. By default it would be null so it would behave identically. But this would then allow extra flexibility to pass a special “context with implicit parameters” down with a function call. This context then could be used by context-aware code to lookup some state dynamically as an alternative to global namespace lookup. I believe this would allow to relatively easily adapt libraries which are written in a style with global state[1] into Sierra’s component-like model, where the code stays the same, but state lookups get just wrapped in a lookup helper. The lookup helper would emit something like this this?doContextLookup(this, "thing-id"):*normal-static-thing-lookup*. This would enable someone “at the top” to invoke library functions with different contexts (again using some helper macro, or something passing a new context as-this). In case of re-frame it would allow having multiple instances of re-frame in a single javascript context despite the way how it was written. btw. This feature has been requested multiple times, I had to write a re-frame fork to deal with that myself[2]. Also I have already experienced the pain[3] of rewriting a not-trivial codebase written in this style to use Sierra’s component model. I ended up passing those ‘context’ parameters down multiple levels into the tiniest functions in my system. Something like this would give me an option to pass contexts as implicit this, but the code would still read the same.

[1] https://github.com/Day8/re-frame/blob/master/src/re_frame/db.cljs#L10
[2] https://github.com/binaryage/pure-frame
[3] darwin/plastic@7c81a4f

@whilo
Copy link
Author

whilo commented Aug 16, 2016

I have updated the gist to incorporate cljs-zones in benchmark 8 and 9.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment