Doing small network scientific machine learning in Julia faster than PyTorch
julialang.org
julialang.org
In "small" or "very sparse" setups, you're memory bound, not compute bound. TF and Pytorch are bad at that because they assume memory movements are worth it and do very little in-place operations.
Different tools for different jobs.
See the flame graph here in the overhead section: https://horace.io/brrr_intro.html
I think pytorch has a genius design and makes excellent tradeoffs. But it's not the perfect solution to every problem. Genericity vs specialization: hard to win on all fronts.
Transferring data to-and-from the PCIe / GPU takes time. If the CPU is faster and can perform the calculation before the PCIe is done transferring, then there's no point even touching the GPU.
PCIe is on the scale of ~5000 nanoseconds (20,000 CPU cycles assuming 4GHz). A PCIe write, then read could be ~40,000 CPU cycles or so, and it turns out that a lot of things can be done in that time before the GPU was even _NOTIFIED_ that there was work to do.
CPUs can contact other CPU-cores within 50 to 500 nanoseconds or so, depending on the distance. A 64-core CPU could then have 64-cores * 18000 clocks == ~1-million CPU-clock cycles before the GPU gets any message at all.
Locality of reference ("Data locality") > Spatial and temporal locality usage : https://en.wikipedia.org/wiki/Locality_of_reference
Memory hierarchy https://en.wikipedia.org/wiki/Memory_hierarchy :
> Most modern CPUs are so fast that for most program workloads, the bottleneck is the locality of reference of memory accesses and the efficiency of the caching and memory transfer between different levels of the hierarchy [citation needed]. As a result, the CPU spends much of its time idling, waiting for memory I/O to complete. This is sometimes called the space cost, as a larger memory object is more likely to overflow a small/fast level and require use of a larger/slower level. The resulting load on memory use is known as pressure (respectively register pressure, cache pressure, and (main) memory pressure). Terms for data being missing from a higher level and needing to be fetched from a lower level are, respectively: register spilling (due to register pressure: register to cache), cache miss (cache to main memory), and (hard) page fault (main memory to disk).
Is it that PCIe is necessarily implied by the debuggable pipeline specified by the von Neumann architecture? https://en.wikipedia.org/wiki/Von_Neumann_architecture
Otherwise, computation within RAM avoids interconnect saturation.
"Neuromorphic" computing, stateful RAM with operators mapped to particle interactions:
Memristor > Derivative devices > memtransistor https://en.wikipedia.org/wiki/Memristor#Derivative_devices
Quantum reservoir computing: https://en.wikipedia.org/wiki/Reservoir_computing#Quantum_re...
But these still need a faster and wider (and qubit) bus than PCIe, too: https://en.wikipedia.org/wiki/PCI_Express
> Lloyd also postulates that the Universe can be fully simulated using a quantum computer; however, in the absence of a theory of quantum gravity, such a simulation is not yet possible. "Particles not only collide, they compute."
Quantum on Silicon looks cheaper in today dollars.
Devide the universe in QFT field-equal halves A and B, take energy from A to make B look like A, then add qubit error correction, and tell me if there's enough energy to simulate the actual universe on a universe QC with no instruction pipeline.
Still, that's around 1800 clock cycles for a 3GHz CPU. Which implies there is a floor, below which you want to keep computations local on-CPU versus communicating over PCIe. Whether it be to a remote system via RDMA, or to a PCIe connected accelerator.
[1] https://www.aravision.com/products/infiniband/pdf_mellanox_a...
I came to the 5000ns estimate because I tested some null-kernel a long time ago on a GPU. Basically a "hello world" of GPU kernels, and I tested to see how long it took for the kernel to be called.
I hear that GPUs have sped up a bit since my test a few years ago, but I'd expect it to still be around 5000ns for a "CPU-GPU ping-pong".
It's a good use case for it, because it needs to be low-latency (it's in the critical path when launching a container at Netflix) and it's using a lot of sparse features to do its job (hashed tokens of a lot of poorly structured metadata about the container and its runtime).
Just wondering why did you choose D when you write the Vectorflow library?
[1]http://blog.mir.dlang.io/glas/benchmark/openblas/2016/09/23/...
> When we get to larger matrix-matrix operations, such as 100x100 * 100x100, we can effectively write off any overheads due to memory allocations. But we definitely see that there is a potential for some fairly significant performance gains in the lower end! Notice too that these gains are realized by using the pure-Julia LoopVectorization.jl as the standard BLAS tools tend to have extra threading overhead in this region (again, not optimizing as much in this region).
> But, if you have been riding the GPU gospel without looking into the details then this plot may be a shocker! However, GPUs are designed as dumb slow chips with many cores, and thus they are only effective on very parallel operations, such as large matrix-matrix multiplications. It is from this point that assumption (2) is derived for large newtork operations. But again, in the case of small networks such GPU kernels will be outperformed by well-designed CPU kernels due to the lack of parallel opportunities.
For the one I am interested in recently (dynamics control), many networks are small feed-forward ones or two-layer LSTMs. The importance there is about balance robust controller v.s. update frequency. If you can run the same controller at higher frequency, it is basically free performance improvement (the robot will be more stable, more responsive etc.).
A single grad student can make large problems work (my thesis was large linear algebra and I could just hack away on enough C and MPI to get it done). In our early days on Julia, we realized that 90% of the world actually needs small linear algebra and it is a tantalizingly difficult problem. The work done in the Julia community over the years has made it all possible through a collaboration across lots of different teams and disciplines.
See for example: https://github.com/JuliaLinearAlgebra/RecursiveFactorization...
For someone with a statistical background, this is a lot of parameters. John von Neumann could wiggle a lot of elephant trucks.
A lot of practical/useful models fill the range from the tiny ones we may use in UDEs and SciML to this MNIST convnet.
I’d quite like to like Julia, but that’s one of my sticking points.
I think Julia is stuck in a spot where they are better than say R/Python for scientific computing on a fundamental language structure level and package manager level, but that it's not good enough to make up for the fact that those languages have a more robust ecosystem.
I'd argue though that this mindshare is not there due to Python/R as a language (and platform) being better, but simply because there were no better alternatives 10-15 years ago and by now the sheer inertia makes it impossible to stop.
You'd need to convince a sufficient portion of people to move to Julia at more or less the same time. Few people want to be first movers. These tend to be the ones who actually care about the qualities of the platform, not just "get the work done and clock out".
If you are a data scientist, you won't be paid for moving to Julia. You'll be paid for coming up with working models. And you take a serious risk by moving to a new platform with little adoption. The platform might die, taking your tools and processes with it. You won't be able to rely on your colleagues advice about technical issues. You can run into bugs more frequently simply because fewer eyes have looked at the ecosystem.
The end result is that few people take the plunge.
Why aren't we moving, you ask? Because moving to another ecosystem does not put bread on the table. I cannot go to clients and say: this past 6 months we made no improvements to our strategies, but look, we migrated to a new programming language that is used by a fraction of a percent of our peer group.
The field attracts clever people and there are many greenfield projects. But just because we start a new project, it doesn't mean that doing it in a tiny ecosystem is sensible. Some firms can do it. Jane Street has the means to basically be "OCAML The Systematic Trading Language". This is not a luxury afforded to most.
And thus we get a chicken and egg problem, where nobody wants to be the sole first mover as there's little advantage to it. At the same time, we all see that everyone would be much better off if we moved.
Julia still has a lot of empty library space compared to R or Python, and isn't perfect, but my guess is it will catch up. R did when it was being compared to SAS, fortran, C/C++, lisp, and so forth.
I'll be honest and say that I wish something else more general-purpose (to the point of say, having a bootstrapped compiler) would be in its spot but I can't really complain right now. Maybe something else will catch up.
I think for me personally is that R and python is a bit in denial about its performance limitations when it comes to hard problems. You pretty much have to drop down into C/C++ to address them, and for certain things, julia really does do many times better time-wise, without the weeds of C/C++. I think there's some people (myself probably included) that are tired of being forced to choose between the C/C++ and R/python worlds. I think there is a bit of overhyping and/or cult-like nature of julia but I also think some of it is trying to convince people that you don't have to choose between expressiveness and performance.
It might not be a perfect relationship - there will be lagging effects and other factors, it will vary based on the specific task, and some of it will be subjective - but you can’t pretend that there is no relationship at all.
There are lits of external factors like costs, available support, availability of compilers for certain hardware, support of the existing tool chain, learning curve, career chances etc.
Python and JavaScript aren't the best but sufficient enough
This isn't true. Sure, you can't unilaterally pick whatever language you want when you work with other people. But for every piece of software ever built somebody had to decide what to build it with. And even though Julia has existed for 10 years, almost nobody is picking it over the alternatives.
You need to gather a certain critical mass of users to get popular. Otherwise Java , JavaScript or Python would have been replaced already.
Most of the time accessibility beats performance.
What do you think why Visual Basic was so popular and why Python is now?
Java started with a first implementation in 1991, but I didn't find it usable for serious production until about 1999-2000. That isn't quite 10 years.
10 years from inception to mature-enough-for-production seems to be kind of the rule. Building support systems and a community is hard, hard work.
I love working on performance. It's fun and challenging. Trying to best high scores (times/benchmarks) is fun and gamifies it.
Personally, I answer tons of questions online (e.g. the Julia discourse and Slack) related to things like SIMD because I find it fascinating and want to share this excitement with others. Same thing when it comes to a blog post or announcing a package; I focus on what motivates me most.
I think my comment was mainly that if I were viewing it objectively (for example, knowing nothing about Julia), I think a lot of Julia's evangelizing comes off too strong. This is definitely not directed at you btw, but just that the community as a whole is just overly positive and ONLY positive.
For example, if you search for Rust on hacker news or even browse /r/rust, you'll need 80-90% pro rust, but you will also definitely see another 10-20% of posts being very critical about Rust. And these critical posts are even from prominent members of the Rust community. Additionally, there's a roadmap for Rust posted very often.
In Julia however, I can count the number of "negative" Julia posts I've seen on one hand. Don't get me wrong, I really like Julia, but there are A LOT of things that need to be improved that I know about and probably more that I don't even realize. It would be nice to have experts post and comment on that, and the core Julia members describe how they are going to tackle that. IMO, that discussion does happen, but it is largely on Slack and forever lost to Slack's history.
I've definitely been making an effort to temper any discussion of benefits with associated drawbacks.
Though, I just want to express WHY I'm so enthusiastic. I've used both R and Python for DS. I'm familiar a bit with matlab.
I've played with Keras and Pytorch.
Aside from all the technical and productivity benefits Julia just feels GOOD to write in, compared to all of the above. It's a real quality of life improvement + a feeling of freedom in that I can express my ideas, compose them with others all without worrying about a different compiler or framework or array type system or having to code units in c++.
This is freeing and honestly, it would be sad if it's relegated to niche status forever. There's definitely a selfish component here, but I'd like everyone to feel the same benefits.
It's hard to go back to pandas or tf or pytorch after using chain.jl, dataframes.jl and flux.
Yes, there are drawbacks with compile times and things like that, which can be annoying. But I keep working in julia because the benefits are worth it, those aren't intrinsic to the language, and most importantly, there's a roadmap to fixing them: https://discourse.julialang.org/t/precompile-why/78770/8 with progress that is tantalizingly close to the end goal of easy dev and easy deployment.
Maybe this my biggest gripe when someone claims Julia is objectively better than Python or R. The answer to this question is highly subjective, heavily depends on whether you use VSCode or not, and people that usually answer this question are heavily invested in getting other people to use Julia.
But the answer is usually presented as an objective answer, and I guess that just kind of rubs me the wrong way.
As someone who spends 98% of his time in Python and the remaining 2% in R I'd love to switch to a more nicely designed, ergonomic, high-performance language. Adopting Julia at work is a nonstarter for now, but what really holds me back from learning it is how thoroughly academic all the evangelism seems to be. Most working data scientists aren't doing things like physics simulations and won't be terribly interested in solving differential equations; even in grad school the topic rarely came up for my econometrics research.
To put it bluntly: reading about Julia kind of makes me feel a bit dumb, not excited.
When I do work with neural networks, the networks just aren't going to be small because the value in them -- for the kind of work I do -- is in their capacity to use large amounts of data to find useful representations. So the benefit of Julia over PyTorch described in the link is only interesting to me in a "huh, I guess that's a bit cool" kind of way.
I, speaking for myself, will keep this in mind next time I set myself to gushing about Julia.
Part of what Julia offers is that you need less tweaking, and the whole stack is in julia so it's quite hackable.
You mention r/rust downthread - r/Julia is quite inactive in comparison. However, the places where Julia discussion actually happens - Discourse, Zulip, etc. - do contain a lot of criticisms, wishlists from other languages, roadmaps for improvements, etc. But these are not places you would randomly come across if you're not involved with Julia, like a subreddit would be.
And the scientific/academic focus of the language that you mention is actually another reason this happens. When the language's users are (primarily) developers, they (we) nitpick and think about alternate designs and blog about them for no reason at all. The average Julia user instead would just ask in the discourse/other forums, maybe complain, and then move on with their research/engineering problem. So the blog articles that end up getting posted here are most often by the core language/package developers announcing new features, breakthroughs, and other positive news.
An example I've asked about previously: When comparing a recursive implementation of an LAPACK operation with OpenBLAS, is that using the RELAPACK(?) implementation in OB, and if not, how does using a similar algorithm in C or Fortran compare? Generally, I want details of measurements.
It doesn't pack, which is why column major A' * B is slow: https://juliasimd.github.io/LoopVectorization.jl/latest/exam... And also why SimpleChains wouldn't scale to large matrix multiplies. But, with 1 MiB L2 cache (e.g. Skylake-X), a 512x256 dense layer of Float32 is still small enough to not really need packing, so I haven't yet needed to implement it (but I will eventually, in a future version of a rewritten LoopVectorization that also actually adds dependency analysis). For an ML library, I'd implement packing via changing the data layout of the parameter vector to tile major, to just skip any runtime packing altogether (i.e., the data would be pre-packed). Only extremely large arrays benefit from a second packing level, so that I don't think it's worthwhile; smaller batch sizes would avoid the need.
The benchmark plots I linked above used dynamically sized arrays. LoopVectorization.jl performed better at small sizes than MKL, and much better than everything else. Compared to that application, LV can also specialize on compile time sizes, fuse the addition of the bias vector, and fuse the activation function.
I tried to use Julia, and all I got was visual studio code complaints about this and that and how the julia plugin cant detect something...ugh
The website doesn't even explain how to get started. I figured out that VScode + plugin was the way to go based of reddit.
Rest your weary installers friend. Never install a new version of Julia by hand again.
But i can agree community is way 2small and compared to big languages tooling are extremely poor.
- if you have one tiny network, no one cares, use pure python
- if you have a bunch of tiny networks, yes, doing a ton of tiny GPU kernel calls will kill you with overhead <-- strawman because ...
- ... you can likely combine them into bulk sparse kernel calls and spread them out. If more of an iterative simulation, same thing, just over time.
This goes back to the 80's with segmented scans, cray, & connection machines. Put all the data together, figure out regular bulk operators, even if individual parts are unbalanced or otherwise irregular, and voila. Since then, a lot of basic tricks for regularizing (sparsity, speculation, ...), and has gone into overdrive w/ GPU era.
If this is as worthy the investments these specialists are doing, as it seems to be many years of R&D by smart groups, I'm surprised they're comparing to an unoptimized GPU alg. Presumably, at some point, they did a more reasonable one?
It used to be that honest CPU<>GPU implementations were generally within 2X of one another here once you considered normalizers like performance-per-watt (= per $), though new CPU HW is closer to GPU HW and new GPU HW is fancier, so one of the funnier results is, algorithmically, you have to use the same fundamental GPU-era tricks either way.
> This problem is far too small to saturate the GPU, even with such a large batch size. Time is dominated by moving batches from the CPU to the GPU. Unfortunately, as the batch sizes get larger, we need more epochs to reach the same accuracy, so we can hit a limit in terms of maximizing accuracy/time.
The batch size in their example is 2048.
This is because adaptive time stepping is only correct if you take the minimum of all of the steps (average would make some above error tolerance, or worse unstable). If you have some ODEs stiff, or worse have stochasticity that can cause random times to shrink the time steps at different points for different solvers, making a solver of the union of equations something that is extremely wasteful. And that becomes a dilemma: we started trying to solve multiple ODEs at the same time to fill a matrix-matrix multiplication kernel, but by doing so we now have to take 100x more matrix-matrix multiplications! So that ended up nowhere, and we focused on matrix-vector performance via direct SIMD and effective use of mutation (as the blog post describes) and got very good results. We are also redoing some of the GPU ODE solvers and seeing about 100x improvement by avoiding batching in a similar way, but that's a post for another time.
All in all, some applications can just slam a big net. Others can't. The two cases need different optimizations.
So the only thing you actually need are the same number of steps for each solve, which can be easily accomplished by just padding out the solves that finish with slightly fewer steps. In practice this ends up introducing negligible overhead whilst solving the above issue very neatly. For example this is precisely what Diffrax (https://github.com/patrick-kidger/diffrax) does under `jax.vmap`.
I've not dug into what Julia does here; is this not already done when broadcasting `DifferentialEquations.solve`?
On GPU vmap isn't as bad at face value, I thought it would be fairly close to optimal, but when we dug in we found it wasn't the right approach for the ODEs we were looking at either. Julia has KernelAbstractions.jl which can do similar code transformations as vmap, but when digging into the CUDA profiler we found it takes a pretty substantial number of trajectories to fill the kernels. There were a few more optimizations we could do to it, but it was reaching its limit rather quickly. Meanwhile, it turns out that if you can just generate .ptx kernels for the ODE, minimize the usage of global memory by keeping all ODE parts locally in registers, and then only couple within warps, you can beat that by about 100x. So some of our other GPU stuff has gone stale for a bit while we've been working to get this approach completed. It was somewhat of a disappointing conclusion because it means we have to really specialize some codes for GPU, but anyways, full details will come probably around the end of summer.
This is a really good question that I don't have a neat answer to. You can actually also hit similar issues when naively vectorising some stiff algorithms that detect when to recalculate Jacobians: at each timestep, some batch elements will want the recalculation whilst some won't.
In both cases, something like what you suggest might be a possible solution. And thankfully support for such custom batching will Soon (tm) be coming to JAX.
In the mean time, Diffrax actually side-steps the problem by not implementing those kinds of solvers. New solvers usually get added on a by-request basis, so this just hasn't been an issue yet.
So:
1. Do you know what DifferentialEquations.jl does in this scenario when running on the GPU? (On the CPU needing to sychronise batch elements usually isn't such a concern.)
2. If the price of using JAX is giving up a bit of performance in this one use case, then I think I'm okay with that. The clear contenders in this space are JAX and Julia, and right now it's a concious choice to be using JAX as a better fit for the problems I'm tackling. This comparison is something I've written about before: https://discourse.julialang.org/t/state-of-machine-learning-....
The main way DifferentialEquations.jl deals with this currently is being faster on the CPU than other solvers are on GPU. For small models, GPU has too much overhead, and for large models, you can do linear algebra within timesteps on the GPU without compromising solver efficiency.
Most of the point of optimized L3 BLAS is optimizing for memory so it's compute-bound asymptotically but, for instance, header-only libxsmm isn't normal BLAS.
(I'm used to just using -Ofast to get vectorization, not having to include a special library.)
LoopVectorization.jl is substantially more powerful. It also has tons of limitations, but I am rewriting it to fix most of them.
Disclosure: I'm the primary author of both SimpleChains.jl and LoopVectorization.jl.
Last I checked, blasfeo did not support AVX512, and this performed poorly on CPUs supporting it.
I'm not really familiar with xsmm, but someone showed me this: https://haampie.github.io/smm-bench/cascadelake/ https://haampie.github.io/smm-bench/skylake-avx512/ LoopVectorization.jl performs best with AVX512, but not badly without it: https://haampie.github.io/smm-bench/znver2/
Versus Eigen, LoopVectorization.jl does better at small sizes (less than a couple hundred, up until packing matters) on my computer.
I haven't looked at implementing these things, but I imagine uf you have smaller networks and thus less data, the large building blocks may not be optimal. You may for example want to fuse some operations to reduce memory latency from repeated memory access.
In PyTorch world, there are approaches for small networks as well, there is https://github.com/NVlabs/tiny-cuda-nn - as far as I understand from the first link in the README, it makes clever use of the CUDA shared memory, which can hold all the weights of a tiny network (but not larger ones).
Warning: FullyFusedMLP is not supported for the selected architecture 70. Falling back to CutlassMLP. For maximum performance, raise the target GPU architecture to 75+.
Warning: FullyFusedMLP is not supported for the selected architecture 70. Falling back to CutlassMLP. For maximum performance, raise the target GPU architecture to 75+.
Initial Train Loss: 5.7188
Initial Test Loss: 5.2812
Took: 11.41 seconds
Train Loss: 0.0354
Test Loss: 0.0514
Took: 11.58 seconds
Train Loss: 0.0327
Test Loss: 0.0511
Took: 11.42 seconds
Train Loss: 0.0316
Test Loss: 0.0505
I think almost of the time here is python overhead because if we increase the batch size 10x, it still takes the same time: Warning: FullyFusedMLP is not supported for the selected architecture 70. Falling back to CutlassMLP. For maximum performance, raise the target GPU architecture to 75+.
Warning: FullyFusedMLP is not supported for the selected architecture 70. Falling back to CutlassMLP. For maximum performance, raise the target GPU architecture to 75+.
Initial Train Loss: 5.5391
Initial Test Loss: 5.5938
Took: 11.03 seconds
Train Loss: 0.0444
Test Loss: 0.0545
Took: 11.16 seconds
Train Loss: 0.0388
Test Loss: 0.0496
Took: 11.01 seconds
Train Loss: 0.0384
Test Loss: 0.0490
See [gist](https://gist.github.com/rejuvyesh/6c428ea12154edbb36cd4359fa...) for the implementation.For an illustrative example, imagine computing the sum of all elements of some `x-1` where x is some numpy vector. Each individual operation is dispatched to numpy without any global context, so it must explicitly allocate a buffer for the x-1 result before computing the sum. The astute observer might note that you can instead use np.subtract(x, 1, out=x) or np.subtract(x, 1, out=buffer) to reduce the impact of allocation, but the problem is _still_ dominated by memory bandwidth on modern hardware (at one point in the past memory access was about as slow as a computation, and now it's 100x slower at least), and having to iterate over roughly the same elements twice rather than doing a bunch of CPU/GPU operations at each element roughly doubles the total computation time if everything doesn't fit an a very fast cache. Even when using numpy to its fullest (which most people don't), the model of stringing together a bunch of local optimizations can't always and doesn't usually compete with the model of at least attempting to globally optimize the whole computation.
it works but surprisingly slow on my devices
Initial Train Loss: 5.9414
Initial Test Loss: 5.9414
Took: 175.90 seconds
Train Loss: 0.0108
Test Loss: 0.0108
Took: 183.89 seconds
Train Loss: 0.0016
Test Loss: 0.0016
Took: 188.95 seconds
Train Loss: 0.0010
Test Loss: 0.0010
SimpleChains is about 0.5 seconds on this computer, about 5 seconds to compile. So you're right, it is slow, too slow, like 300x faster than Jax, and I suspect something is wrong with the Jax script. It should probably be closer to like 2.5x or so because of memory handling and vectorization stuff, but not any slower than PyTorch.Tidying up the JAX script, I find that it runs in ~15 seconds on my laptop CPU. Likewise, fixing the crash error in the blog post (UndefVarError: alloc_threaded_grad not defined) then I find that the Julia implementation also runs in ~15 seconds on my laptop CPU.
So, 1x faster than JAX?
Clearly we're getting very different results here, so (a) I'd like to get to the bottom of this, but (b) perhaps we should be more cautious about making performance claims.
(EDIT: updated script in this GitHub gist: https://gist.github.com/patrick-kidger/68bf7b99ba02c246b20ea...)
I think the main slowdown was doing the model updates out of the JIT region. JAX-without-JIT is ridiculously slow.
https://gist.github.com/patrick-kidger/68bf7b99ba02c246b20ea...
Anyway, something to investigate if I ever get the time.
There were not any breaking changes, but enough fixes and additions (e.g., the convolutional layer and threading) that 0.2.0 isn't really comparable.
Odds are there is a version conflict in 0.2.1 (which also bumped dependency requirements) that does not exist in 0.2.0, and that this is preventing you from upgrading. You could start a smaller environment, or try `] add SimpleChains@0.2.1` to force it/get an error message telling you what the conflict is.