arcadia-unity / Arcadia

Clojure in Unity
http://arcadia-unity.github.io/
Apache License 2.0
1.68k stars 108 forks source link

closureless anonymous functions not hoisting #96

Closed timsgardner closed 9 years ago

timsgardner commented 9 years ago

Presumably if an anonymous function has no closures it can be hoisted to the top level, wherever that is, and constructed just once, rather than however many times it might be in an inner loop (or whatever). This is not happening.

(ns anonohoist)

(defmacro time-it-mac
  ([n & body]
     `(do
        (. clojure.lang.RT (StartStopwatch))
        (dotimes [_# ~n] ~@body)
        (let [t#  (. clojure.lang.RT StopStopwatch)]
          (/ t# ~n)))))

(defn test-hoisting []
  [(time-it-mac 1e5
     ((fn [x] x) :hi))

   (let [f (fn [x] x)]
     (time-it-mac 1e5 (f :hi)))])

With the above AOT'd:

anonohoist=> (test-hoisting)
[0.00015 1E-05]

~ 15 times slower.

Disassembled invoke for test-hoisting:

public override object invoke ()
{
    object[] expr_06 = new object[2];
    int arg_69_1 = 0;
    RT.StartStopwatch ();
    long num = RT.longCast (100000.0);
    for (long num2 = 0L; num2 < num; num2 += 1L)
    {
        ((IFn)new anonohoist$test_hoisting$fn__399__403 ()).invoke (anonohoist$test_hoisting__413.const__4);
    }
    long x = RT.StopStopwatch ();
    expr_06 [arg_69_1] = Numbers.divide (x, 100000.0);
    int arg_D7_1 = 1;
    object obj = new anonohoist$test_hoisting$f__406__410 ();
    RT.StartStopwatch ();
    long num3 = RT.longCast (100000.0);
    for (long num4 = 0L; num4 < num3; num4 += 1L)
    {
        ((IFn)obj).invoke (anonohoist$test_hoisting__413.const__4);
    }
    long x2 = RT.StopStopwatch ();
    expr_06 [arg_D7_1] = Numbers.divide (x2, 100000.0);
    return RT.vector (expr_06);
}

The anonymous function is constructed 100,000 times in the first case, once in the second. (Bit weird that that only slows us down by 10x.) Also, even in the second case we seem to be unnecessarily casting the fn obj to IFn 100,000 times, not sure what the perf implications of that are.

timsgardner commented 9 years ago

Just thought of an (admittedly weird) case where hoisting a closureless anonoymous fn makes a semantic difference:

(def holder (atom nil))

(defn test-hoisting-assumption []
  (let [state (atom nil)
        log (atom [])]
    (let [f (fn bla []
              (if (not= @holder bla)
                (do (reset! holder bla)
                    false)
                true))]
      (dotimes [_ 2]
        (reset! state (f))))
    (swap! log conj @state)
    (reset! state nil)
    (dotimes [_ 2]
      (reset! state
        ((fn bla []
           (if (not= @holder bla)
             (do (reset! holder bla)
                 false)
             true)))))
    (swap! log conj @state)))

Then:

anonohoist=> (test-hoisting-assumption)
[true false]

Maybe named self-reference counts as a closure? Are there any cases in which a closureless anonymous function without named self-reference cannot be hoisted?

timsgardner commented 9 years ago
(defn test-hoisting-assumption-2 []
  [(loop [f nil, i 3]
     (let [f2 (fn [x] x)]
       (if (< 0 i)
         (recur f2 (dec i))
         (= f f2))))

   (let [f2 (fn [x] x)]
     (loop [f nil, i 3]
       (if (< 0 i)
         (recur f2 (dec i))
         (= f f2))))])
anonohoist=> (test-hoisting-assumption-2)
[false true]

how do programming languages even work you guys

What about a case in which a closureless anonymous function cannot be hoisted to just under the closest recursion point - loop or fn or whatever? And maybe we should try to prove the hoisting thing rather than just looking for counterexamples off the top of our heads

timsgardner commented 9 years ago

If you hoist to just under the nearest recursion point you aren't going to pop the function out of an inner loop anyway, so it would be completely pointless. So long as you have first-class functions with reference equality semantics (unlike, say, Mathematica, where functions have lexical equality semantics, or whatever you want to call it), you can't safely hoist them while preserving the semantics of not hoisting them. Closing this issue as stupid-idea-in-the-first-place.

nasser commented 9 years ago

No, I'm not convinced this is a dumb idea.

Paying GC/allocation cost for using a pure function is kind of insane. Really, there should be one instance of the function class at all and you just use that. A singleton, to use OO design pattern parlance.

15x penalty is pretty bad. Requiring users to manually optimize using let bindings is also pretty bad.

timsgardner commented 9 years ago

It isn't just a perf thing. How would hoisting work logically so long as functions have reference semantics? (not= (fn [x] x) (fn [x] x))

timsgardner commented 9 years ago

This works though:

(defn test-hoisting-assumption-3 []
  [(loop [f nil, i 3]
     (let [f2 (fn [x] x)]
       (if (< 0 i)
         (recur f2 (dec i))
         (= (class f) (class f2)))))

   (let [f2 (fn [x] x)]
     (loop [f nil, i 3]
       (if (< 0 i)
         (recur f2 (dec i))
         (= (class f) (class f2)))))])
anonohoist=> (test-hoisting-assumption-3)
[true true]
nasser commented 9 years ago

Do you mean that hoisting would break the equality semantics of fns?

(= (fn [x] x) (fn [x] x))
;; false

(defn foo [x]
  :bar)

(= foo foo)
;; true

(= #'foo #'foo)
;; true

I could see the first expression returning true if we hoist/do a singleton thing

nasser commented 9 years ago

Your example works because each fn form in the source gets its own class, so everything is referring to that one class. Mine returns false because there are two fn forms.

timsgardner commented 9 years ago

Yes, it breaks the equality semantics of fns, which is fundamental to the meaning of the language. Mathematica gets away with lexical equality because it's a symbolic system with anonymous functions represented as transparent rewritable expressions. fn forms in Clojure are like reify forms, every time you hit one you're supposed to construct something (it now seems to me) and things get weird if you don't.

nasser commented 9 years ago

:(

that is my formal response

nasser commented 9 years ago

So anonymous functions should never be used in an inner loop, then? That kind of sucks.

timsgardner commented 9 years ago

For example, if you don't construct something new at every fn form you have the equality semantics oscillating wildly depending on whether the fn form closes over something, which is nutso

nasser commented 9 years ago

Right right right

timsgardner commented 9 years ago

Yeah never use anonymous functions in an inner loop I guess

nasser commented 9 years ago

worst christmas ever

timsgardner commented 9 years ago

I hope all our repo watchers are appreciating the nuances of this chat-in-an-issue

timsgardner commented 9 years ago

Always Remember: Lambdas Are For Losers, Lisp Is For Lame-Ohs

nasser commented 9 years ago

KTC needs a t-shirt printer for gems like that

swannodette commented 9 years ago

Closures aren't free. You'll encounter the same trouble pretty much everywhere (i.e. JavaScript etc.). Some languages like Java 8 do optimize these cases but the win is going to small unless it works for pipelines at which point you might as well be using transducers (Java 8 does a transducer like transform).

nasser commented 9 years ago

That makes sense. It sucks to have to pay the perf cost even when the anonymous function isn't closing over anything, though.