Performance is weird

I have a lot of performance data about tartrazine since it’s a project with a very deterministic output on which I have done extensive performance work, and it’s very easy to benchmark.

I wanted to get a historic perf benchmark for the different releases, which all include static alpine-musl binaries.

Well, it was weird.

The blue lines are benchmarks of each release built on my machine. The red lines are benchmarks of each release using the static binaries from the release (lower is better).

See that performance loss? Static binaries got a lot slower at one point.

Of course this covers many months in time, so it could be many things:

  • Crystal version
  • Dependency versions
  • Alpine version

After lots of compiling and comparing …

  • In musl, static builds, crystal 1.21.0 is much slower than 1.20.1
  • In glibc, dynamic builds, 1.21.0 and 1.20.1 perform the same

After debugging, the performance variation can be attributed to … garbage collection.

  • In 1.21.0 + static + musl the heap size is “grow as you go”
  • If I set it to start at 16MB then it’s fastest of all compiler versions I tried
1.21.0 static build, sqlite3.h → html wall time
default GC settings 94.7 ms
GC_INITIAL_HEAP_SIZE=16M 49.2 ms
1.20.1 default (control) 54.5 ms

This is a 50% reduction in wall time, entirely from changing a parameter!!! :rofl:

  • Not the dependencies
  • Not the compiler version
  • Not the operating system

The heap size.

While this is probably not universally applicable (do you really care about a 40ms startup shave?) it was super fun to figure out!

Interesting investigation, but a few points are still unclear to me.

  • GC_INITIAL_HEAP_SIZE is provided by Boehm GC, not Crystal.
  • From a DeepWiki scan of the Crystal codebase — with some hallucination risk — Crystal does not seem to configure it and simply leaves it to Boehm GC.

So could this be due to a recent change in libgc, where different linked versions caused different initial allocation behavior?

Also, assuming the same libgc version, why would dynamic linking be faster than static linking when memory is allocated “as you go”?

Yes, it’s a Boehm GC thing.

No, I don’t know why it changed.

Further research required I guess

Would you mind giving gcry GitHub - sdogruyol/gcry: Crystal’s GC, written in Crystal. · GitHub a chance on the same workload

Good news!

time
tartrazine static-musl + Boehm + GC_INITIAL_HEAP_SIZE=16M 53.5 ms
tartrazine native + gcry 48.0 ms
tartrazine native + Boehm 45.2 ms

Minor issues: gcry doesn’t build static against musl because it uses getdents64 and cross-compiling crashes the compiler :-)

A bit further investigation, seems like GC initialization is quite a chunk for binaries that do only one thing fast in musl-based systems.

In “normal” dynamic, glibc systems, GC startup is much faster (tens of milliseconds!)

Here are runs on a tiny file where startup (pre-main work) dominates:

config wall pre-main (exec→user code) in-process
Boehm, default 12.7 ms 12.0 ms 0.67 ms
Boehm, GC_INITIAL_HEAP_SIZE=16M 9.4 ms 8.7 ms 0.74 ms
no GC (-Dgc_none) 10.2 ms 9.5 ms 0.68 ms
gcry 12.4 ms 11.7 ms 0.63 ms

And here is a large file where the actual work dominates:

config wall pre-main in-process
Boehm, default 45.4 ms 13.1 ms 32.3 ms
Boehm, GC16 40.0 ms 7.7 ms 32.3 ms
no GC 44.3 ms 10.1 ms 34.2 ms
gcry 44.7 ms 12.8 ms 31.9 ms

I think it’s interesting that starting with a large heap appears to be faster than leaking memory, and also that gcry seems to be within noise of Boehm’s default behaviour!

It’s great to see gcry being on par with Boehm at least :slight_smile: gcry is %100 Crystal :+1:

Sounds like Monitor thread causes performance regression · Issue #17144 · crystal-lang/crystal · GitHub

Can you try a crystal nightly release to see if Add `GC.syscall(&)` by ysbaddaden · Pull Request #17150 · crystal-lang/crystal · GitHub has any effect? Also, what about GC_MARKERS=1 to disable parallel collections?

GC_MARKERS=1 doesn’t make a difference:

v0.25.0 static binary 6-byte input (cold start) 690 KB input
default 51.4 ms 74.0 ms
GC_MARKERS=1 52.3 ms 74.9 ms
GC_INITIAL_HEAP_SIZE=16M 9.2 ms 31.5 ms

But good news! Crystal nightly almost completely removes the penalty!

v0.25.0 static binary 6-byte input 690 KB input
Crystal 1.21.0, default GC 51.4 ms 74.0 ms
Crystal 1.22.0-dev, default GC 12.7 ms 35.9 ms
Crystal 1.21.0 + GC_INITIAL_HEAP_SIZE=16M 9.2 ms 31.5 ms
Crystal 1.22.0-dev + GC_INITIAL_HEAP_SIZE=16M 10.0 ms 31.5 ms