Rust to Nim: A Comparison

This is a Rust to Nim comparison, similar to the Crystal to Rust post below.

Here are a few observations.

1. Going from Rust to Nim was a straightforward translation, mainly in semantics.

2. Rust is faster doing the number<->string conversion version, but Nim is faster creating palindromes numerically.

3. Nim's raw and stripped executables are significantly smaller.

4. For Nim, sometimes using slice gapfuls[0..kep-1] is faster than the array gapfuls.

5. Conclusion: For this specific task, it's a split decision on speed. If program size is a priority (embedded system, etc) Nim wins. Rust carries a larger runtime, but that's nothing new.

The system spec for all cases are (Nim compiled using gcc and clang):

System: I7-6700HQ, 3.5 GHz, Linux Kernel 5.9.10, GCC 10.2.0, LLVM 10.0.1
Rust 1.4.8, Nim 1.4.0
Compile: $ nim c --cc:[gcc|clang] --d:danger palindromicgapfuls.nim
Compile: $ rustc -C opt-level=3 -C target-cpu=native -C codegen-units=1 -C lto palindromicgapfuls.rs 
Run as:  $ ./palindromicgapfuls
Palindromes created using number<->string conversions
____________|  speed  | exe (raw) bytes | exe (stripped) bytes
rust        | 19.973s |   1,373,528     |     268,576
nim (gcc)   | 25.326s |     113,776     |      92,504
nim (clang) | 26.484s |      89,480     |      67,592

Palindromes created directly numerically
____________|   speed | exe (raw) bytes | exe (stripped) bytes
rust        |  8.769s |   1,368,352     |     268,576
nim (gcc)   |  8.309s |      91,856     |      72,024
nim (clang) |  8.446s |      83,896     |      63,856

Here's the Nim code.

import strutils, typetraits  # for number input
import times                 # for timing code execution
import unicode               # for reversed

proc palindromicgapfuls(digit, count, keep: int): seq[uint64] =
  var skipped = 0                       # initial count of skipped values
  let to_skip = count - keep            # count of unwanted values to skip
  var gapfuls = newSeq[uint64]()        # array of palindromic gapfuls
  let nn = digit * 11                   # digit gapful divisor: 11, 22,...88, 99
  var (power, base, basep) = (1, 1, 0)
  while true:
    if (power.inc; power and 1) == 0: base = base * 10
    var base11  = base * 11             # value of middle two digits positions: 110..
    var this_lo = base * digit          # starting half for this digit: 10.. to  90..
    var next_lo = base * (digit + 1)    # starting half for next digit: 20.. to 100..
    while this_lo < next_lo - 1:
      var (palindrome, palindrome_base, left_half) = (0'u64, 0'u64, this_lo.intToStr)
      let right_half = left_half.reversed
      if (power and 1) == 1: basep = base11; palindrome_base = (left_half & right_half).parseUInt
      else: basep = base; left_half.removeSuffix("0"); palindrome_base = (left_half & right_half).parseUInt
      #else: basep = base; palindrome_base = ((left_half.removeSuffix("0"); left_half) & right_half).parseUInt
      for i in 0..9:
        palindrome = palindrome_base + (basep * i).uint
        if (palindrome mod nn.uint) == 0:
          if skipped < to_skip: (skipped += 1; continue)
          gapfuls.add(palindrome)
          if gapfuls.len == keep: return gapfuls
      this_lo += 10
import times

proc make_palindrome(front_half: uint64, power: int): uint64 =
  var (result, front_half) = (front_half, front_half)
  if (power and 1) == 0: result = result div 10
  while front_half > 0:
    result = result * 10
    result += front_half mod 10
    front_half = front_half div 10
  result

proc palindromicgapfuls(digit, count, keep: int): seq[uint64] =
  var skipped = 0                       # initial count of skipped values
  let to_skip = count - keep            # count of unwanted values to skip
  var gapfuls = newSeq[uint64]()        # array of palindromic gapfuls
  let nn = uint64(digit * 11)           # digit gapful divisor: 11, 22,...88, 99
  var (power, base) = (1, 1)
  while true:
    if (power.inc; power and 1) == 0: base = base * 10
    var base11  = base * 11             # value of middle two digits positions: 110..
    var this_lo = base * digit          # starting half for this digit: 10.. to  90..
    var next_lo = base * (digit + 1)    # starting half for next digit: 20.. to 100..
    while this_lo < next_lo - 1:
      let basep = if (power and 1) == 1: base11 else: base
      var palindrome = make_palindrome(this_lo.uint64, power)
      for _ in 0..9:
        if palindrome mod nn == 0: (skipped.inc; if skipped > to_skip: gapfuls.add(palindrome))
        palindrome += basep.uint64
      if gapfuls.len >= keep: return gapfuls[0..keep-1]
      this_lo += 10

Here is the common output code.

let start = epochTime()
 
var (count, keep) = (20, 20)
echo("First 20 palindromic gapful numbers ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (100, 15)
echo("\nLast 15 of first 100 palindromic gapful numbers ending in:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (1_000, 10)
echo("\nLast 10 of first 1000 palindromic gapful numbers ending in:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (100_000, 1)
echo("\n100,000th palindromic gapful number ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (1_000_000, 1)
echo("\n1,000,000th palindromic gapful number ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (10_000_000, 1)
echo("\n10,000,000th palindromic gapful number ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
echo (epochTime() - start)

Nim Rosetta Code

Rust Rosetta Code

1 Like

What [profile.release] settings were you using for Rust? In my experience, stripped Rust binaries can get pretty small.

The code was compiled as shown.
I've seen (on this forum) where "Hello World" was reduced significantly to just bytes, but I'm nowhere near that level with Rust. I'm sure someone can get it down to matching Nim, if they feel like going thru the exercise. This was submitted for informational purposes, I thought I'd just share it (like I did for Crystal).

If you indicate the following in your Cargo.toml, the resulting binary will be significantly smaller:

[profile.release]
lto = true
panic = "abort"

Make sure to build with cargo build --release, then run strip yourself on the binary found in target/release/. Further, it's this release binary that should be used to measure performance. Nobody expects much from the default debug mode in terms of performance :stuck_out_tongue:

Note: There are settings to make the Rust binary even smaller, but the ones I showed above are the ones I've seen produce the most immediate difference.

4 Likes

In regards to code speed:

I don't know nim so wouldn't be able to provide tips on how to optimize it's speed.
Reducing allocations in the Rust version is possible and would probably provide a significant speedup:
In palindromicgapfuls - Instead of returning a Vec<u64> it is possible to:

  • Either reuse a Vec by calling clear, instead of allocating a new one each time.
  • or, it might be possible to return impl Iterator which will lazily return values to be printed.

The string version could save an allocation too, the intermediate right_half string allocation could be removed by using iterators to construct the final string.

It's best to benchmark code with a crate like criterion to get more reliable stats.

1 Like

I would encourage people to post actual code to show how to make this task faster or have a smaller binary. You know what they say, an example of actual working code is worth more than a 1000 suggestions. :slightly_smiling_face:

2 Likes

I would suggest trying different Nim garbage collectors. Especially --gc:arc, all of them are listed here. You can also use nim cpp to test the C++ backend if you want to aswell.

flywind posted the benchmark on Nim discord so I decided to take a look at it.

It appears to me that the two versions were not equivalent, in details:

  • Within the processing loop, the Rust version employs push_str() to concatenate into an existing buffer, while the Nim version used the & operator, which allocates a brand new string. I modified the Nim version to use add() which is our equivalent.
  • The Nim version used removeSuffix(), which is rather inefficient as it has to perform comparisons compared to the Rust version use of pop(). I adjusted it into setLen(str, str.len - 1) which is the equivalent.
  • The Rust version put these variables as immutable: base11, this_lo, next_lo. I don't expect this to affect performance though, but I adjusted the Nim version accordingly anyway.
  • The Rust version was compiled with additional optimizations like LTO and CPU-specific optimizations, while the Nim version just got -O3 that comes with -d:danger.
  • The Nim version got runtime checks disabled due to the use of -d:danger. I switched to -d:release, which should keep the runtime checks enabled.

There might be more differences, but anyway, here's how I compiled the programs and system specs:

System: AMD Ryzen 5 3600, Linux 5.9.10, GCC 10.2.0, LLVM 11.0.0, Rust 1.48.0, Nim 1.5.1 (latest development version)
Compile: $ nim c -d:release -d:lto --passC:-march=native bench.nim
Compile: $ rustc -C opt-level=3 -C target-cpu=native -C codegen-units=1 -C lto bench.rs
Run as:  $ ./bench

The results:

version time exe (raw) bytes exe (stripped) bytes
rust 19.187s 307200 270336
nim 17.797s 81920 69632

And below is the updated Nim code:

import strutils, typetraits  # for number input
import times                 # for timing code execution
import unicode               # for reversed
 
proc palindromicgapfuls(digit, count: uint64, keep: Natural): seq[uint64] =
  var skipped = 0u64 # initial count of skipped values
  let
    to_skip = count - keep.uint64  # count of unwanted values to skip
    nn = digit * 11                # digit gapful divisor: 11, 22,...88, 99
  var
    power = 1
    base = 1u64
  while true:
    inc power
    if (power and 1) == 0:
      base *= 10
    let
      base11 = base * 11           # value of middle two digits positions: 110..
      this_lo = base * digit       # starting half for this digit: 10.. to  90..
      next_lo = base * (digit + 1) # starting half for next digit: 20.. to 100..
    for front_half in countup(this_lo, next_lo - 2, 10):
      var
        left_half = $front_half
        basep = 0u64
      let right_half = left_half.reversed
      if (power and 1) == 1:
        basep = base11
        left_half.add right_half
      else:
        basep = base
        left_half.setLen left_half.len - 1
        left_half.add right_half
      var palindrome = left_half.parseUInt.uint64
      for _ in 0 ..< 10:
        if palindrome mod nn == 0:
          inc skipped
          if skipped > to_skip:
            result.add palindrome
        palindrome += basep
      if result.len >= keep:
        result.setLen(keep)
        return

let start = epochTime()
 
var (count, keep) = (20u64, 20)
echo("First 20 palindromic gapful numbers ending with:")
for digit in 1u64..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (100u64, 15)
echo("\nLast 15 of first 100 palindromic gapful numbers ending in:")
for digit in 1u64..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (1_000u64, 10)
echo("\nLast 10 of first 1000 palindromic gapful numbers ending in:")
for digit in 1u64..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (100_000u64, 1)
echo("\n100,000th palindromic gapful number ending with:")
for digit in 1u64..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (1_000_000u64, 1)
echo("\n1,000,000th palindromic gapful number ending with:")
for digit in 1u64..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (10_000_000u64, 1)
echo("\n10,000,000th palindromic gapful number ending with:")
for digit in 1u64..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
echo (epochTime() - start)
1 Like

Why the size of rust's executable is so big compared to the nim? Is it a lack of optimization?

It's mainly Rust's standard library and machinery to handle backtraces and panics. It's a constant overhead β€” executables aren't 4x larger, they're 200KB larger.

Nim compiles to C, so it probably leverages C standard library that you already have as part of your operating system, so it doesn't need to bundle as much in its executables.

7 Likes

Hey thanks @leorize for optimizing the Nim string version. I'm maybe a medium level Nim programmer, so I really learned allot about string manipulation from your code. Unfortunately, Nim's documentation doesn't really show you how to do real stuff with it, so your code revealed more in a short review than hours I spent trying to peruse its docs.

  1. I first copied and ran your bench code as is, using -d:release and -d:danger. There's really no need for runtime checks, and the code is faster/smaller. Then I took your code and reformatted it to match my original version and the results are literally the same. (These are best times from a number of runs, but there wasn't much statistical difference).

  2. I did find an anomaly depending solely on formatting. When I changed just the original snippet below to the top form, it's 4 secs slower. However when changed to the 2nd form, it's just (maybe) a little slower. I would consider the first case a bug/regression, since these are valid code formats, and compile.

var
  left_half = $front_half   --> var (left_half, basep) = ($front_half, 0u64) 
  basep = 0u64              --> var left_half = $front_half; var basep = 0u64

Here are results compiled w/o -d:[release|danger]

_________________|   speed  | exe (raw) bytes | exe (stripped) bytes
my ver (danger)  |  18.303s |      73,224     |      59,656
bench  (danger)  |  18.384s |      73,218     |      59,656

my ver (release) |  19.183s |      78,560     |      63,752
bench  (release) |  19.101s |      78,560     |      63,752

Here's my reformatted version. Coming from Ruby, I like oneliners, and like to read code horizontally vs vertically. TEHO! (To Each His/Her Own)

# nim c -d:[release|danger] -d:lto --passC:-march=native filename.nim

import strutils, typetraits  # for number input
import times                 # for timing code execution
import unicode               # for reversed
 
proc palindromicgapfuls(digit, count, keep: int): seq[uint64] =
  var skipped = 0                     # initial count of skipped values
  let to_skip = count - keep          # count of unwanted values to skip
  let nn = digit * 11                 # digit gapful divisor: 11, 22,...88, 99
  var (power, base, digit) = (1, 1u64, digit.uint64)
  while true: 
    power.inc
    if (power and 1) == 0: base *= 10
    let base11  = base * 11           # value of middle two digits positions: 110..
    let this_lo = base * digit        # starting half for this digit: 10.. to  90..
    let next_lo = base * (digit + 1)  # starting half for next digit: 20.. to 100..
    for front_half in countup(this_lo, next_lo - 2, 10):
      var 
        basep = 0u64
        left_half = $front_half     
      let right_half = left_half.reversed
      if (power and 1) == 1: basep = base11; left_half.add right_half
      else:                  basep = base;   left_half.setLen left_half.len - 1; left_half.add right_half
      var palindrome = left_half.parseUInt.uint64
      for _ in 0..9:
        if palindrome mod nn.uint == 0: (skipped.inc; if skipped > to_skip: result.add palindrome)
        palindrome += basep
      if result.len >= keep: result.setLen(keep); return

let start = epochTime()
 
var (count, keep) = (20, 20)
echo("First 20 palindromic gapful numbers ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (100, 15)
echo("\nLast 15 of first 100 palindromic gapful numbers ending in:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (1_000, 10)
echo("\nLast 10 of first 1000 palindromic gapful numbers ending in:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (100_000, 1)
echo("\n100,000th palindromic gapful number ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (1_000_000, 1)
echo("\n1,000,000th palindromic gapful number ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
(count, keep) = (10_000_000, 1)
echo("\n10,000,000th palindromic gapful number ending with:")
for digit in 1..9: echo(digit, " : ", palindromicgapfuls(digit, count, keep) )
 
echo (epochTime() - start)

Just a minor question. It seems that the rust version in your test is 1.4.8. Is this a typo?

No, it's the current 1.48

➜  ~ rustup show
Default host: x86_64-unknown-linux-gnu
rustup home:  /home/jzakiya/.rustup

installed toolchains
--------------------

stable-x86_64-unknown-linux-gnu
nightly-x86_64-unknown-linux-gnu

active toolchain
----------------

stable-x86_64-unknown-linux-gnu (default)
rustc 1.48.0 (7eac88abb 2020-11-16)


Yea, unfortunately documentation remains a weak point of the project. Considerable efforts have been put into improving them, but we still have a long way to go. For now I'd recommend dropping in either the Nim forum or one of our official chatrooms for support and guidance.

The reason why it's slow is because tuple unpacking (the var (x, y) = ("", 0) syntax) is currently not optimized for strings and seqs (they always result in a copy afaict), which should be fixed once the "new runtime" coming alongside the new memory management system become the default as we have move optimizations there to solve the issue.

Nim shells out low-level IO to the C library. All other facilities (incl. backtrace, exceptions, x to string, etc. with the exception of float-to-string) are written in Nim and is bundled into the resulting executable.

Here's the resulting size if you statically link the C library into the executable:

version exe (raw) bytes exe (stripped) bytes
nim (dynamic musl) 76336 67416
nim (static musl) 109536 95864
nim (dynamic glibc) 81920 69632
nim (static glibc) 856064 774144

The commands used to produce these executables:

$ nim c -d:danger -d:lto --passC:-march=native
$ nim c -d:danger -d:lto --passC:-march=native --passL:-static

The same two commands was ran on two different systems, one using Gentoo/glibc and one using Gentoo/musl.

If you wonder why the numbers for glibc are so huge, it's because glibc wasn't designed with static linking in mind and its objects aren't well separated, so statically linking glibc pulls a huge portion of the library into the executable (and you should never statically link with glibc, but that's not relevant here).

Hey, thanks again @leorize for the info.

Here's another question hopefully you can answer.

This snippet

left_half.setLen left_half.len - 1; left_half.add right_half

can't be written like this

left_half.setLen(left_half.len - 1).add right_half

or this

(left_half.setLen left_half.len - 1).add right_half

because left_half.setLen left_half.len - 1 doesn't immediately return its value, the way most languages do. Is this behavior planned to be changed so you can do at least the latter version? I would think it should be easy to write the compiler to know that (xx operation) wants the results returned, not a void type.

I was really getting into Nim up to a couple of years ago, but for things not strictly related to the language itself (that I won't get into here), I got turned off to it. Also, coming from Ruby, where programmer happiness was the primary goal for Matz, all these little quirks, and continual resistance to document them and listen to its users to fix them, soured me to it. (Really, do you have to be so different to force people to do x [mod,div,and,or] y vs x [%, /, &, |] y). Nim has useful benefits for its niches it could excel in, but its devs, (especially Araq) seem more into creating something they like, and force users to conform to it, than to create a language that is easy and enjoyable for people to use. My intent here is not to rag on Nim, but merely to provide you feedback to make it better.

Again, you have been very helpful, and I've learned allot from your examples. I just wish that were more generally true about Nim.

We already got an answer to this: the with library. With this your snippet can be written as:

import std / with

left_half.with(setLen left_half.len - 1, add right_half)

or

import std / with

with left_half:
  setLen left_half.len - 1
  add right_half

For performing the same but on a copy, we got sugar.dup:

import sugar

let new_left = left_half.dup(setLen left_half.len - 1, add right_half)
# or
let new_left = left_half.dup:
  setLen left_half.len - 1
  add right_half

We believe that this solution is much more elegant and composable than the proc(T): T pattern while requiring zero modification to existing code, so it's now a part of the standard library as of version 1.2. The new macros also removes the need of having two different in-place and out-place operations, as now all can be written as in-place operations and dup() can seamlessly convert them into out-place.

It's rather unfortunate that you were turned off by Nim. However the language has changed a lot throughout the years and Araq has opened up to many ergonomics suggestions, so I would really love if you could give Nim an another try. The community is very open and we would be happy to take constructive criticisms as we strive to improve the language.

We got a bit of a Pascal heritage there, and a large part of the community is in favor of it, which is why that stayed.

Yes, it is no denying that Araq is extremely opinionated. However as the language grows he (and the community) has start to open up more to feedback on improving it's ergonomics and programmers general satisfaction.

However it's worth noting that Nim don't attempt to be a replacement of any language (be it C or Python), but it attempts to be the language (this is my interpretation, please don't read too much into it :P), and as such we take serious consideration into what to learn from other languages and what not to.

As stated above, Nim has a strong heritage from other "lesser known" languages like Pascal, Oberon and Modula-3, as such new users coming from C-style languages may get put off by some syntactic decisions such as mod over %, div for /, etc. I understand that you may not agree with all of these decisions, but if you're trying out Nim I'd recommend just bear with it. My personal experience with languages is that you have to accept how things work to fully appreciate and learn from them.

Thanks. If you've got the time, I'd like to invite you to give Nim an another take. The community has grown and is very friendly, and is currently much more active than usual as Advent of Code is happening (we also have a private leaderboard† if you're into that).

If you have any further questions, please don't be hesitate to ask me, though preferably you should direct them to either the Nim forum or one of the bridged chatrooms (I'm active in both of them), as I think in-depth discussion regarding Nim is rather off-topic here in the Rust forum.

† Sorry but I can't link the Nim forum here as I've used up my links quota for the documentation.

Again @leorize thank you for your responses. But also again, what you just showed me is nowhere apparent to do in the documentation. (This is one reason I look at Rosetta Code examples, to see real use language cases.)

Look, I'm an Adult, so while I have preferences, and see some things as irritations, I'll still use the language if it worth it (that's particularly true about Rust :grinning_face_with_smiling_eyes:) .

But language designers really should try to make them easy to use, if they want the most people possible to use them (maybe some don't care). Some decisions just seem to be more about ego/hubris than technical necessity.

Example.
Ruby: palindrome = (left_half.chop + right_half).to_i
Crystal: palindrome = (left_half.rchop + right_half).to_u64
:blush: :blush: :blush: :blush: :blush:

This is so simple and clear; easy to program and easy to read/understand.

How hard can it be, for a compiled language, to create easy and simple semantics to delete the last character of a string (I'm looking at you too Rust!).

Anyway, I still do follow the Nim forums, but was banned from posting there anymore (talk to me offline and I'll tell you the story).

Look I admit, I'm spoiled by the ease, and fun, of programming in Ruby (Crystal).
I am taking the time, and putting in the effort, to learn Rust, Nim, et al.
I'd just wish they'd try harder to make them easier to use (take on more of the weight of simplifying common tasks for users), and stop making programmers be mini-compilers, to figure out the details of how to do the simplest things. Programmer productivity is many times more important than how fast (in absolute execution terms) the code can be.

This is hard because unicode is hard, not because the languages are being difficult. For example, what's the expected outcome of removing the last "character" in "Hello πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"? Is it "Hello "? Is it "Hello πŸ‘¨β€πŸ‘©β€πŸ‘§"? Is it something else? (Note that neither of those first two proposals is as simple as just removing the last unicode scalar value or code unit.)

8 Likes

I think you're making this example waaay more complex than it needs be.

[r|chop] means drop the last (printable) character in the string (no matter what format). Simple!

I'm sure the Rust devs are at least as smart as the Ruby|Crystal ones, so if there's any doubt, just mimic the results of Rust|Crystal. If users need something different, they can roll their own version (which you're essentially forced to do now).

But to me, this response exemplifies the difference in thinking between their devs. You see it as purely a technical one, Ruby|Crystal treat it as a feature for users, and takes care of the gory details on how to do it.

And basically that's my point, that's a manifestation of every language, which are all designed by humans. You create what you want based on the philosophy of what you see as the most important. So Rust makes users jump through multiple hoops to do the most common things, while Ruby gives you multiple ways to do most thing, in the simplest (with the least surprises) way possible.

You would be an extreme masochist to try to write a Ruby on Rails framework in Rust, but maybe not so much to rewrite the Linux kernel (as being done in chunks).

It's not so much technical stuff that determines technology, but more so the imagination and philosophy of their creators.