eastwood

All things realted to eastwood - the Clojure linter
slipset 2019-07-12T13:44:55.047Z

I’m hoping to get some time to work on Eastwood this fall, and I’d really like to see if there are ways to make it go faster, since linting our code base (around 50kloc) is starting to take quite a bit of time.

slipset 2019-07-12T13:46:03.048500Z

From my initial investigations, it seems like most time is spent in analyzing the source-code, and that the linters themselves are pretty fast.

slipset 2019-07-12T13:48:47.052500Z

So I was wondering if @bronsa or @andy.fingerhut had any thoughts on attack vectors here? As for me I’ll probably start by running the linting under yourkit to see if there are any obvious improvements to be had, but my fear is that this is not about making the existing implementation faster, but rather about finding another way to solve the problem, if that makes sense.

slipset 2019-07-12T13:50:14.054100Z

And, I should probably make a github issue of it, since whatever is discussed here is lost after a bit of time.

borkdude 2019-07-12T14:00:34.054600Z

are you applying for clojurists together maybe?

slipset 2019-07-12T14:01:12.055Z

No. I’m asking my employer to give me some days

borkdude 2019-07-12T14:01:38.055600Z

ah ok, because I saw eastwood (and clj-kondo) being mentioned as projects people would want to support through it

borkdude 2019-07-12T14:01:49.056Z

but time is more of an issue than money probably

borkdude 2019-07-12T14:02:57.057600Z

isn't part of what makes it slow mostly the evaluation that tools analyzer is trying to do?

slipset 2019-07-12T14:03:14.057900Z

Yeah, I saw that too, but yeah, time is more of an issue. And even if I would get time off from work to work on Eastwood for three months, I’m not sure I would be able to fill three months with eastwood work.

borkdude 2019-07-12T14:03:51.058700Z

I found this a handy tool to do performance analysis: https://github.com/clojure-goes-fast/clj-async-profiler

slipset 2019-07-12T14:04:26.059600Z

Yeah, I think that’s what the slowness is caused by, but I’d need time to look into exactly how the analysis is performed, if it can be performed in other ways.

slipset 2019-07-12T14:05:15.060800Z

I have spent some time on trying to do it in parallel, but that also requires some thinking on my part. And I’m not good at thinking when I’m working from home after work, kids and everything else.

borkdude 2019-07-12T14:05:28.061Z

makes sense

slipset 2019-07-12T14:06:52.062100Z

I don’t know how much time @bronsa has put into making the analyzer blazingly fast either (not blaming him nor the analyzer, just saying I don’t know).

bronsa 2019-07-12T14:07:28.062400Z

haven't put any time into it at all

bronsa 2019-07-12T14:07:38.062700Z

it's highly likely that the overhead is entirely in the analyzer

slipset 2019-07-12T14:08:05.063500Z

If so, the project becomes even more interesting 🙂

bronsa 2019-07-12T14:08:08.063600Z

@ambrosebs has done some work on a custom version of t.a.jvm that's a bit more performant

bronsa 2019-07-12T14:08:09.063800Z

I think

bronsa 2019-07-12T14:08:28.064500Z

don't think there's a clear win anywhere, it's death by thousand cuts

bronsa 2019-07-12T14:09:06.065800Z

t.a.jvm was never designed to be particularly performant, but to be extensible/clear, so it is highly possible that theres slowness inherent in its design

borkdude 2019-07-12T14:09:14.066200Z

@slipset fwiw, I'm using this very simple profiling macro for clj-kondo to see how much % of the time is spent where: https://github.com/borkdude/clj-kondo/blob/master/src/clj_kondo/impl/profiler.clj

borkdude 2019-07-12T14:09:36.066900Z

e.g. 40% of linting time is spent in parsing

slipset 2019-07-12T14:10:09.067500Z

@bronsa that’s what I’m fearing too (that it’s many small things), but it’d be good to at least get that as a fact (which I’ll try to do by profiling)

slipset 2019-07-12T14:10:53.068Z

@borkdude there is some simple timing stuff in eastwood as well.

slipset 2019-07-12T14:11:13.068500Z

The main thing is that I haven’t quite dug into how the analyzer works.

slipset 2019-07-12T14:11:39.069100Z

AFAIK eastwood feeds the analyzer with one ns at the time, which may or may not be optimal

slipset 2019-07-12T14:13:02.070200Z

This approach is probably great if you want to use Eastwood from inside eg Cider where you lint one file at a time, but might not be optimal when you’re running Eastwood on a whole project, like you’d do when running in CI.

bronsa 2019-07-12T14:14:44.070400Z

not that simple to parallelise

bronsa 2019-07-12T14:14:56.070800Z

ns analysis is side-effectful

bronsa 2019-07-12T14:15:06.071Z

clj isn't declarative as cljs

slipset 2019-07-12T14:16:02.072Z

yeah, we talked about that previously. Eastwood uses tools.namespace(?) to figure out in which order the project ns’s need to be analyzed

slipset 2019-07-12T14:16:46.072700Z

If you could find islands in the graph that tools.ns creates, those islands could be done in parallel.

slipset 2019-07-12T14:17:16.073500Z

But again, this (for me) requires thinking, which is not something I do well late at night after work.

bronsa 2019-07-12T14:17:22.073700Z

yes, in theory if everybody plays nice

bronsa 2019-07-12T14:17:30.074Z

in practice that may still not be enough

bronsa 2019-07-12T14:17:50.074500Z

there's code in the wild that relies on namespace loading ordering, implicitely

slipset 2019-07-12T14:18:16.075300Z

It’d had to be behind a setting, so you would opt in for parallelisation (what a word to type)

bronsa 2019-07-12T14:18:23.075500Z

but then it becomes a matter of compromising/parametrising

bronsa 2019-07-12T14:18:26.075700Z

yeah sure

2019-07-12T16:12:35.076700Z

From now on, messages here should be copied into long-history-searchable Clojurians Zulipchat https://clojurians.zulipchat.com

2019-07-12T16:13:36.077700Z

I don't have any thoughts on ways to significantly speed up Eastwood. There is perf measuring code as a command line option in Eastwood today, and sounds like you have seen that with recent Eastwood versions most of the time is in analysis, and bronsa is the expert on that step.

2019-07-12T16:15:47.079400Z

I haven't used flycheck or similar editor add-ons that people have made to use Eastwood on their code as they type, but I suspect what happens there is that they just run Eastwood pretty much continually, starting another run soon after the last one completes, filtered by changes made to files? Something like that, probably. The reason I mention it is that in such a scenario, maybe one doesn't even notice how long it takes Eastwood to run, because you do not manually invoke it.

2019-07-12T16:16:17.080100Z

It's sort of the "install a mirror next to a slow elevator" kind of approach, to attack the psychology of the people involved rather than changing the performance of the system 🙂

2019-07-12T17:47:56.081100Z

yea, fwiw here's the main things my analyzer does differently from tools.analyzer https://github.com/clojure/core.typed.analyzer.jvm#differences-from-toolsanalyzerjvm

2019-07-12T17:48:31.081900Z

probably the biggest performance win is not using resolve-{ns,sym} and allowing custom implementations for each platform

2019-07-12T17:48:56.082500Z

but if you don't need incremental analysis it might be a bit overkill to use

slipset 2019-07-12T18:49:05.083700Z

Just from poking around a bit, I see that eg analyzer.jvm/build-ns-map is called a lot with the same count of ns’s

slipset 2019-07-12T18:50:00.084500Z

Seems like for Eastwood, it could be called once. (but I’m only poking around, not understanding it correctly)

2019-07-12T18:54:51.085600Z

It wouldn't be difficult to experiment with a change to build-ns-map that calls all-ns and looks up the return value in a memoization map, returning the cached value on a hit.

slipset 2019-07-12T18:55:16.086100Z

I’m just about to try that out 🙂

2019-07-12T18:55:25.086300Z

Such an experiment would at least tell you if it saves much time.

slipset 2019-07-12T18:55:47.086700Z

Just not seeing where all-ns is defined…

2019-07-12T18:56:22.086900Z

It is in core.

slipset 2019-07-12T18:56:58.087800Z

clojure.core?

2019-07-12T18:57:26.088200Z

Also, just a warning about the memoization there -- I expect 99% of the time it will return the same value if you memoize like that, but note that namespace objects are mutable, and can have new aliases and Vars added to them over time, so the memoized version could return a subset of what the non-memoized one would.

2019-07-12T18:58:14.089Z

I have no idea what fraction of Clojure code might be affected by that.

slipset 2019-07-12T18:59:03.089900Z

anyways, it’d be interesting to see what effect it has on timing

slipset 2019-07-12T18:59:21.090400Z

I also changed the impl a bit

2019-07-12T18:59:33.090700Z

agreed. I hope it saves a bunch of time, and rarely causes changes in behavior 🙂

slipset 2019-07-12T18:59:45.090900Z

(defn build-ns-map []
  (persistent!
   (reduce (fn [acc ns]
             (let [ns-name' (ns-name ns)]
               (assoc! acc ns-name' {:mappings (merge (ns-map ns) {'in-ns #'clojure.core/in-ns
                                                                   'ns    #'clojure.core/ns})
                                     :aliases  (reduce-kv (fn [a k v] (assoc a k (ns-name v)))
                                                          {} (ns-aliases ns))
                                     :ns       ns-name'}))) (transient {}) (all-ns))))

slipset 2019-07-12T19:00:07.091300Z

Haven’t measured if it gives any perf gains, but might be faster.

2019-07-12T19:01:31.091400Z

yes

slipset 2019-07-12T19:08:07.092Z

it most certainly caused a change in behaviour 😕

2019-07-12T19:09:57.092900Z

if it is called a bunch of times, e.g. once for each top level form in a file with the code of one namespace, then the value of the :mappings key would differ significantly from the first form to the last in a file.

2019-07-12T19:10:16.093100Z

wait, I might be wrong about that ...

slipset 2019-07-12T19:13:28.093900Z

memoized:

slipset 2019-07-12T19:13:35.094200Z

src dev test
== Linting ardoq.logger ==
done in 678.707711 ms
== Linting ardoq.schemas ==
done in 598.816618 ms
== Linting ardoq.utils ==
done in 947.06261 ms
== Linting ardoq.core ==
done in 478.296453 ms
== Linting ardoq.service.metadata-service ==
done in 250.096475 ms
== Linting ardoq.client.client-utils ==
done in 290.824036 ms
== Linting ardoq.persistence.mongo.mongo ==
done in 237.54222 ms
== Linting ardoq.system ==
done in 29.137272 ms
== Linting ardoq.utils.sanitizer ==
done in 51.532309 ms
== Linting ardoq.utils.org-utils ==
done in 99.718969 ms
== Linting ardoq.utils.commons-utils ==
done in 54.200468 ms
== Linting ardoq.utils.service-utils ==
done in 40.833131 ms
== Linting ardoq.utils.encoder ==
done in 197.032283 ms
== Linting ardoq.specs ==
done in 689.955477 ms
== Linting ardoq.service.heartbeat-service ==
done in 345.340966 ms
== Linting ardoq.persistence.crud-store ==
done in 149.723064 ms
== Linting ardoq.pubsub-service ==
done in 341.467959 ms
== Linting ardoq.service.event-service ==
done in 252.765624 ms
== Linting ardoq.persistence.mongo.repo ==
done in 215.895817 ms
== Linting ardoq.persistence.organization-store ==
done in 546.132456 ms
== Linting ardoq.utils.system-utils ==
done in 98.541928 ms
== Linting ardoq.persistence.repo ==
done in 198.904394 ms
== Linting ardoq.persistence.model-store ==
done in 160.790918 ms
== Linting ardoq.utils.model-adapter ==
done in 120.944057 ms

slipset 2019-07-12T19:13:50.094700Z

unmemoized

slipset 2019-07-12T19:13:52.095Z

src dev test
== Linting ardoq.logger ==
done in 801.748673 ms
== Linting ardoq.schemas ==
done in 1197.414751 ms
== Linting ardoq.utils ==
done in 2149.84938 ms
== Linting ardoq.core ==
done in 1071.887829 ms
== Linting ardoq.service.metadata-service ==
done in 514.068053 ms
== Linting ardoq.client.client-utils ==
done in 597.463114 ms
== Linting ardoq.persistence.mongo.mongo ==
done in 586.476757 ms
== Linting ardoq.system ==
done in 51.211548 ms
== Linting ardoq.utils.sanitizer ==
done in 63.239537 ms
== Linting ardoq.utils.org-utils ==
done in 234.722717 ms
== Linting ardoq.utils.commons-utils ==
done in 118.071369 ms
== Linting ardoq.utils.service-utils ==
done in 86.937504 ms
== Linting ardoq.utils.encoder ==
done in 423.066819 ms
== Linting ardoq.specs ==
done in 1637.538032 ms
== Linting ardoq.service.heartbeat-service ==
done in 892.486891 ms
== Linting ardoq.persistence.crud-store ==
done in 396.092711 ms
== Linting ardoq.pubsub-service ==
done in 888.633896 ms
== Linting ardoq.service.event-service ==
done in 739.321349 ms
== Linting ardoq.persistence.mongo.repo ==
done in 467.753098 ms
== Linting ardoq.persistence.organization-store ==
done in 1557.947442 ms
== Linting ardoq.utils.system-utils ==
done in 277.459718 ms
== Linting ardoq.persistence.repo ==

2019-07-12T19:14:01.095400Z

OK, experimenting with ns-map calls, I think my statement above is correct. ns-map returns a mapping of symbols to Vars for all currently-def'd Vars in the namespace. That set of currently-def'd Vars in the namespace definitely grows as more forms are eval'd within a namespace, while analyzing it.

slipset 2019-07-12T19:14:40.096Z

Such sadness, the memoized version takes basically half time.

2019-07-12T19:15:10.096700Z

When you say it definitely changes the behavior, do you mean that it changes warnings found by Eastwood?

slipset 2019-07-12T19:15:56.097300Z

It makes the source unlintable:

slipset 2019-07-12T19:15:58.097500Z

== Linting ardoq.fake.fake-subscription-service ==
Exception thrown during phase :analyze+eval of linting namespace ardoq.fake.fake-subscription-service
Got exception with extra ex-data:
    msg='Could not resolve var: defn'
    (keys dat)=(:var :file :end-column :column :line :end-line)
ExceptionInfo Could not resolve var: defn

slipset 2019-07-12T19:16:07.097800Z

After linting a bunch of ns’s

slipset 2019-07-12T19:20:35.098700Z

at least my reduce/persistent! seems a couple of ms faster pr ns.

slipset 2019-07-12T19:22:23.100Z

Ah, but there seems to be a way to half the calls to build-ns-map

slipset 2019-07-12T19:22:38.100300Z

(defn global-env []
  (atom {:namespaces     (build-ns-map)

         :update-ns-map! (fn update-ns-map! []
                           (swap! *env* assoc-in [:namespaces] (build-ns-map)))}))

2019-07-12T19:23:04.101Z

Perhaps the expression for calculating the value of the :aliases key could be memoized, based on the return value of ns-aliases? I don't know how much time is spent evaluating that expression vs. the rest of it, but if it is a large fraction, that might help reduce compute time, without changing the results.

slipset 2019-07-12T19:24:06.101600Z

no, I was reading to fast. Time to let the brain rest.

slipset 2019-07-12T20:54:38.103300Z

It seems like that in analyzer.jvm/macroexpand-1 we call build-ns-map twice if we have a macro or an inline-fn. I guess that’s needed since we can do most anytning in a macro, but it does seem a bit crude.

2019-07-12T21:01:55.103800Z

I think there are some macros that expand to multiple def's on different vars

2019-07-12T21:02:56.104700Z

Run Eastwood on a collection of a few hundred different open source Clojure projects, and you find some weird stuff. You also find semi-weird stuff in Clojure's implementation itself, or at least it can seem weird the first time you encounter it.

2019-07-12T21:03:24.105200Z

Clojure/Java is a very dynamic language with mutable namespaces, and a few library authors have taken advantage of that.

slipset 2019-07-12T21:09:50.105900Z

Yeah, but in analyzer.clj there is special handling for def forms which update the ns-map directly for instance

2019-07-12T21:11:39.107300Z

I wouldn't be surprised if you are able to find some redundant work being done by tools.analyzer, by the way. Just trying to point out perhaps-surprising kinds of Clojure code that exists.

slipset 2019-07-12T21:12:05.107600Z

Much appreciated 🙂

slipset 2019-07-12T21:12:38.108Z

And much learning to be done here as well 🙂

2019-07-12T21:13:43.108700Z

I should maybe make a list of whatever I remember coming across that surprised me while debugging Eastwood -- only a few examples stick out in my mind now, years later.

2019-07-12T21:20:41.109800Z

There are a few macros that have side effects while compiling code that invokes the macro. Clojure's gen-class, maybe? Not sure if I ever saw others.

2019-07-12T21:21:11.110200Z

Maybe one or two other macros with names beginning with def in Clojure.

2019-07-12T21:22:29.111200Z

I have a vague memory of some code redefining the meaning of catch, intended to override the behavior of a catch clause inside of a try. I can't find it right now, in a couple minutes of poking around.

2019-07-12T21:25:05.112Z

The potemkin library takes advantage of some dynamicity in ways that most libs don't, but gets used by a fair number of other libs.