Ruby vs. Crystal Performance(ptimofeev.com) |
Ruby vs. Crystal Performance(ptimofeev.com) |
(Crystal is a fine language and compiler - but it's nothing to do with Ruby.)
I do think that people trying to compare Crystal to Ruby kind of miss the point though. Ruby as an interpreted language, even optimized with JIT compilation, will never match the performance you can get out of a true compiled language. By the same token, Crystal as a compiled language will never be as quick to develop with since you have to wait for your code to compile after each change.
It doesn't have Kernel#eval. It doesn't have Kernel#send. It doesn't have Kernel#binding. It doesn't has Proc#binding. It doesn't have Kernel#instance_variable_get/set. It doesn't have Binding#local_variable_get/set. It doesn't have BasicObject#method_missing. It doesn't have BasicObject#instance_eval. I could go on. All these methods have extreme far reaching non-local implications on the semantic model and practical performance, and specifically defeat many conventional optimisations.
> To the point where many Ruby scripts are completely valid Crystal, or at the very least require only a few changes.
You can't even load most of the Ruby standard library without these methods!
And it doesn't matter if you use them or not. They're still there and they impact semantics and performance because the fact that you can use them affects performance. You can't even speculate against most of them as they're so non-local.
Rails and the rest of the mainstream Ruby ecosystem fundamentally depend on them.
> and are also semantically very close
Sorry I super disagree with this. They look similar. Dig into it just below the surface? Start to model it formally? Not at all. Method dispatch, which is everything in Ruby, isn't even close.
(Again, Crystal's great as its own thing, it's just not similar to Ruby's semantics. If you don't need Ruby's semantics or you can replicate them at compile time then maybe it's perfect for you.)
Of course, harder than it sounds, lots of specifics to figure out.
# Cons cell
class Cell
include Enumerable
attr_reader :car
attr_accessor :cdr
def initialize(car, cdr)
@car = car
@cdr = cdr
end
# Yield car, cadr, caddr and so on, à la for-each in Scheme.
def each
j = self
begin
yield j.car
j = j.cdr
end while Cell === j
j.nil? or raise ImproperListException, j
end
end # Cell
and
https://github.com/nukata/little-scheme-in-crystal/blob/v0.2... # Cons cell
class Cell < Obj
include Enumerable(Val)
getter car : Val # Head part of the cell
property cdr : Val # Tail part of the cell
def initialize(@car : Val, @cdr : Val)
end
# Yield car, cadr, caddr and so on, à la for-each in Scheme.
def each
j = self
loop {
yield j.as(Cell).car
j = j.as(Cell).cdr
break unless Cell === j
}
raise ImproperListException.new(j) unless j.nil?
end
end # Cell
and they will make the point clear.
Ruby and Crystal are different languages, but you can translate your code from Ruby to Crystal line by line fairly easily.For the performance boost, see https://github.com/nukata/little-scheme/tree/v1.3.0#performa... which shows times to solve 6-Queens on a meta-circular Scheme as follows:
* Crystal 0.34.0: crystal build --release scm.cr: 2.15 sec.
* Crystal 0.34.0: crystal scm.cr: 9.88 sec.
* Ruby 2.3.7: ruby scm.rb: 84.80 sec.
Compiled (and complex enough) Crystal code runs 39 times faster than the equivalent Ruby code in this case.
JS you can redefine anything, Java support is pretty good, C# better support is coming with generators
The more extensive the meta programming you can do, the more work it is to implement this under the scenes. For example in Java you can change the visibility of fields, and you can load new classes, so that's not too hard to take account of, but in Ruby you can redefine methods, add refinements so they behave differently depending on where they are called, or radically change the inheritance hierarchy. Implementations like TruffleRuby can maintain high performance even with these features being used, but it's taken a lot of work to achieve that.
Optimising away the performance impact of most of the metaprogramming features I mentioned there requires truly heroic optimisations, beyond what has ever been used for any other language.
Some of them are even worse - I'm not sure there any way to optimise away the non-local effects of Proc#binding, which allows you to access local variables not lexically referenced.
I think the most exciting optimization people have seen with Ruby is Truffle.
I don't regret the 14 years I put into writing Ruby professionally. I've used and abused metaprogramming, and it has shaped how I reason and architect things. I learned to appreciate well-designed, semantically-meaningful DSL. But I've moved on. I write server code with Elixir these days, and I'm exploring other ways of reasoning and writing code.
no offence to JS, but JS and a proper programming language are not even the same species.
> When doing operations between integers, Ruby will make sure to create a Bignum in case of overflow, to give a correct result.
> Now we can understand why Ruby is slower: it has to do this overflow check on every operation, preventing some optimizations. Crystal, on the other hand, can ask LLVM to optimize this code very well, sometimes even letting LLVM compute the result at compile time. However, Crystal might give incorrect results, while Ruby makes sure to always give the correct result.
- The syntax is lovely. No, really.
- I hate waiting for it to compile, especially compared to Go's compile time.
- It's really young yet, and the ecosystem is just getting started.
Matter of taste; I find it horrible, just as I find Ruby syntax horrible. And I do not care about syntax too much generally (among my production langs are k and clojure) but I find this an eye sore; don’t know why but it is what it is.
Edit: aaah downvotes for an opinion :) Anyway, background; I maintained a huge Rails codebase for years; it was pretty much the worst thing I ever did (in 30 years of production coding) and that was pretty much down because how much I don't like the syntax. That doesn't happen often.
You're allowed to hate Crystal's syntax. I'd be curious to hear why, though.
You got downvoted for not liking something "just because". (I didn't downvote.)
https://github.com/luckyframework/lucky/blob/master/src/luck...
~ % cat test.rb
puts "Hello, world"
~ % time ruby test.rb
ruby test.rb 0.03s user 0.05s system 25% cpu 0.312 total
~ % time crystal run test.rb
Hello, world
crystal run test.rb 1.48s user 1.09s system 72% cpu 3.559 totalBecause of this, working with Ruby is actually substantially slower than working with a similarly sized Go project, in my experience, even though Go theoretically has the disadvantage of needing to compile things before running them.
Plus, Go's type system will catch tons of bugs that Ruby optimistically treats as "maybe you meant to do this". Obviously Rust's type system is really awesome, but Go's is still really helpful, while compiling much faster than Rust.
Since Rust and Crystal are both LLVM-based, I would guess that Crystal's compile times also tend to be a bit painful on medium-sized projects, like Rust, but I've never actually used Crystal for anything more than "Hello, World".
(I've been paid to work full time with Ruby, Rust, and Go over the years, and they each have pros and cons, but I just don't think I would ever personally choose Ruby for any new project in 2020, if I had a say in it, given how great Go is for web development.)
crystal build program.cr
./program
There are some amazing things I love about Ruby, like the metaprogramming and monkeypatching ... but I have moved on. I do my server work with Elixir these days.
I built my startup using crystal and now I write crystal full time.
I’m still only using a single dedicated server with a SQLite database.
I know this may sounds strange but I would not be surprised once Crystal reached 1.0 there will be a Ruby implementation written in Crystal.
[1] https://github.com/ffwff/lilith [2] https://news.ycombinator.com/item?id=21860713
I would say the elevator pitch is "A systems programming language with an expressive syntax, similar to Ruby".
Also it has really powerful type inference, which allows you to write really Ruby-like code while maintaining your static typing. The downside of that is that it makes the compiler kind of slow.
I believe that now they're trying to reduce marketing that relies on Ruby and present more as an independent language (which it is).
More here:
asdf plugin-add crystal https://github.com/asdf-community/asdf-crystal.git
asdf global crystal 0.34.0
It was painless. Other languages I've installed with ASDF must have had the same dependencies.Both Ruby and Python use arbitrary-precision integers throughout. I thought Crystal would do the same, given the frequent comparisons to Ruby. I assume the reason it doesn't is to enable AOT compilation (i.e. not only because Crystal is statically-typed)?
Is there a statically-typed language that does use arbitrary-precision integers throughout?
More generally, what other features are common in dynamic languages and rare in static ones, that aren't directly related to type-safety?
JavaScript has fixed precision.
Get back to me when there is Crystal on Rails.
tl;dr "yes, that one is faster and looks similar to the other one so our developers will be less scared and this is good" is important and valid but "that one is just the fast language but slower" is not a valid comparison, which I'm kind of getting.
The biggest gain I've found in using Crystal vs Ruby is reduced memory usage.
Most often my code isn't slow because of the language, but the data access behind it, so counting CPU cycles is less of a priority for me.
https://gist.github.com/s0kil/155b78580d1b68768a6c601a66f8e2...
It also has a lightweight threading system that makes it work well as a web server. Not quite as elegant as something like Elixir, with its isolated processes, but still nice. It also supports macros, which can make the code a lot more elegant and performant compared to similar languages.
Pain points with crystal have mostly been about one of the couple packages i depend on have a breaking change. It was common for crystal to deprecate an API which makes its way to a dependency. Its less common now in the last few months. Maybe I'm just not using the lock file correctly?
There is a little verbosity when parsing user input, like JSON or query parameters, but I've embraced writing bigger schemas or logic for validating input and its made the software better.
BUT, one thing that has been absolutely important was a static compiler. I remember using node or ruby and how many bugs i would catch later in prod after refactoring. Crystal helps big there, as would elixir since its functional but its not statically typed.
Elixir scales but I'm not a huge company yet. I want raw speed right now for the few customers i have. Elixir was a close second.
Crystal compiles slower than most languages, but (except for my website) I just use it as an API server and everything else is a Preact app so I spend most time on the Preact/Webpack stack.
Are you using a framework? Please tell us more :)
No frameworks. the only external crystal dependencies are: sqlite3, awscr-s3, and jwt.
I was using just crystal template views (ECR) on the frontend but its not maintainable or advanced enough so I'm slowly transitioning everything to Preact.
If I start getting scaling problems I’d have to start using multiple servers and at that point I’d need Postgres.
But we’re at ~36 employees and 8 figures of revenue on a single server.
I can probably go another year or 2 without a big database.
I would also argue that the ubiquity of things like BLAS wrappers largely blurs these lines. For example, one of the big reasons I choose Python over Java (my company's primary language) for my work is that, thanks to numpy, Python absolutely smokes Java at crunching numbers, for my purposes. This despite Java being a compiled static language and Python being an interpreted dynamic language.
You can't make a Ruby program that uses fixnums-only. You also can't make a Crystal program that's dynamic like Ruby. Interestingly, though, when the Crystal implementation is modified to use bignums, the Ruby implementation is over twice as fast while still being dynamic.
What I get from this is that Crystal's static compiler is slower than Ruby's dynamically dispatched interpreter, and Crystal's bignums are so slow you'll need to think hard about whether you want a 50x speed boost or correct arithmetic in all cases.
I think Crystal is a good concept, but it's only version 0.34.0. Every implementation is bad at version <<1.0. Ruby <<1.0 didn't have good performance, either. I'm sure Crystal will be great by the time it gets to 2.6.5, too.
JS on the other hand can do most of what ruby does, to my knowledge. Objects are key value pairs so you're free to mess with them in virtually any way you please. You can also mess with the inheritance by altering JS prototype chains.
I don't think metaprogramming itself has much to do with the speed of Ruby, with my admittedly limited knowledge of this stuff
If you want to find out more about the limits of what can be done to optimize Ruby, check out the Truffle project. That came out of someone's PhD dissertation on novel methods for doing JIT optimization for Ruby. It is sufficiently difficult and novel to warrant awarding a PhD for. Last time I heard, Truffle still could not run Rails.
With good code structure, I see large Java projects compile small changes in seconds, even though compiling Java used to be a hog. You don't often rebuild from scratch during development, do you?
But the point is having a different view of what compilation means in the developer's workflow as a language designer. Having the engineer have to think about how to organize the code for the compiler is bad design unless that organization is built into the compiler. The compiler should reject programs that are not organized for optimal compilation. And the organization required at least does not impede understanding of the code (best if it improves it). This is Go's design imprimatur and it's critically important to the success of Go.
FWIW, I see large Java projects compile small changes take minutes to compile, even using hot-reload tools.
Figwheel in Clojure is not like this, however: they're doing something right there.
Making your code low-coupling if equally beneficial for the compiler and for the human to reason about the code. Hence modularization, limiting the visibility of parts, etc.
OTOH there are situations when you have to have a common interface which is used across the board. Imagine Java's `List` or `CharSequence`. If you touch it, you have to recompile all the innumerable uses of it. So the more pervasive the dependency is, the smaller and simpler and more fine-grained it should be. Java's `List` does not do a hugely good job in the compactness department; it's pretty stable, though. You want the same trait from your most foundational interfaces.
http://www.rosettacode.org/wiki/Mutual_recursion#Ruby
I find almost every other implementation better on the eyes; picolisp, pari/gp, nim and some others I would call elegant; the Ruby version looks like overly noisy and fairly ugly to me. Not so much (but still) the function defs, but p (Array.new(20) {|n| F(n) }) I find pretty nasty for some reason (again, not sure why).
Randomly picked example.
And I say this coming from a python/django background. And ORM is one big thing that Rails/Django definitely are clear leaders in.
I don't have the greatest laptop, and run a lot of containers, so Jetbrains IDE's are generally a no-go for me and I stick to VS Code, but trying to develop Ruby/Rails without a Jetbrains IDE Is crippling.
I think ctrl+click definition jump does work in Crystal and it has a decent language server.
I really like the sales pitch of rails, but as someone that almost exclusively writes in modern, compiled languages, it's really tough to adjust to ruby and be productive with it.
At the time, I really enjoyed it, but I'd never written a modern-feeling typed/gradually typed language yet (C#, which I found really verbose and ugly, a lot of pre-ES6 JS and Ruby, some Lua + Python).
I would never voluntarily write it again. Ruby/Rails completely falls apart in larger projects if the entire team doesn't stick to best practices + stringent code and doc standards with stuff like YARD or Sorbet. And so many magic, implicit methods injected everywhere.
Super happy writing Typescript and occasional Rust/Go now. I thought Kotlin was pretty nice too, the one small project I had to implement in it.
Last I checked, there wasn't great editor support for Crystal yet, but that might have changed by now.
It's a never ending discussion, I think all of these stacks are "good enough" to get the job done, a lot of it is how your brain ticks and what you're experienced with...
I was fixing an issue in one common library; properly testing changes required rebuilding and restarting a number of containers. Unit tests only tell you so much; you need proper integration tests to see how certain things interact.
If I were used a statically typechecked language (e.g. TypeScript), I could have eliminated 50%, or maybe 75% of the testing, because the compiler would check things for me before runtime. It would be drastically faster to localize and fix the bug even if the compilation increased build times 10x.
>Sorry I super disagree with this. They look similar. Dig into it just below the surface? Start to model it formally? Not at all.
I think you're missing the point. If 90% of Ruby code works in Crystal unmodified (even if it's because the standard library had to be rewritten from scratch), then the programmer experience may well be quite similar, regardless of how fundamentally different they are if you model them formally.
Are Newtonian mechanics and Einstein's theory of general relativity "similar"? If you model them formally, they look nothing alike. But in 99% of practical situations in every day life, and even in the most precise experiments we could conduct for hundreds of years, they're so similar we can't tell the difference.
False, premise, since it's not the case at first place. 90% of your Ruby code will absolutely not work in Crystal unmodified.
Another example: if 0% of Ruby code works in Crystal unmodified, but for 90% of code the transformation was extremely simple and mechanical like using curly braces {...} instead of begin...end and prepending $ to all variable names like Bash and PHP, they would still feel extremely similar in practice, albeit obviously less similar than the above example.
By contrast, Java and JavaScript are widely described as having very similar syntax, but it is rare to translate code from one to the other without require fundamental rethinking, because the relationship between JS objects, functions, and prototypes is so different from between Java objects, methods, and classes.
Don't nail me on exact 90%, but for me it does. Nothing rails related though. Good example: https://news.ycombinator.com/item?id=23437035
However, I agree that the fundamentals/underlyings are very different. It's far from being like a python 2 to 3 migration.
Java and C also share a similar syntax, but that does not make that you can easily swap one for the other.
Not that you can't have a great, large Rails codebase but the "guardrails" are a lot less present than in some languages and it can be harder to try to clean up the mess once it's happened.
Yet none of this is the one true OO. All of it remains a way of describing a human mode of expression, and so is rightly subjective.
Programming Paradigms and Beyond, Shriram Krishnamurthi and Kathi Fisler:
OO is a widely-used term chock-full of ambiguity. At its foundation, OO depends on objects, which are values that combine data and procedures. The data are usually hidden (“encapsulated”) from the outside world and accessible only to those procedures. These procedures have one special argument, whose hidden data they can access, and are hence called methods, which are invoked through dynamic dispatch. This muchseems to be common to all OO languages, but beyond this they differ widely:
* Most OO languages have one distinguished object that methods depend on, but some instead have multimethods, which can dispatch on many objects at a time.
* Some OO languages have a notion of a class, which is a template for making objects. In these languages, it is vital for programmers to understand the class-object distinction, and many students struggle with it (Eckerdal & Thune, 2005). However, many languages considered OO do nothave classes. The presence or absence of classes leads to very different programming patterns.
* Most OO languages have a notion of inheritance, wherein an object can refer to some other entity to provide default behavior. However, there are huge variationsin inheritance: is the other entity a class or another (prototypical) object? Can it refer to only one entity (single-inheritance) or to many (multiple-inheritance), and if the latter, how are ambiguities resolved? Is what it refers to fixed or can it change as the program runs?
* Some OO languages have types, and the role of types in determining program behavior can be subtle and can vary quite a bit across languages.
* Even though many OO aficionados take it as a given that objects should be built atop imperative state, it is not clear that one of the creators of OO, Alan Kay, intended that: “the small scale [motivation for OOP] was to find a more flexible version of assignment, and then to try to eliminate it altogether”; “[g]enerally, we don’t want the programmer to be messing around with state” (Kay, 1993).
In general, all these variations in behavior tend to get grouped together as OO, even though they lead to significantly different language designs and corresponding behaviors, and are not even exclusive to it (e.g., functional closures also encapsulate data). Thus, a phrase like “objects-first” (sec. 6.1)can in principle mean dozens of wildly different curricular structures, though in practice it seems to refers to curricula built around objects as found in Java.