Foreword

Because of the buzz word "Full Stack developer", there are so many code boot camps teaching javascript courses. In a very short period of time, the candidate needs to figure out the frontend (React), backend (Nodejs), and database(NoSQL). Each of these is a super big spectrum that takes time to discover and study. I am quite confident to say that:

"It is easy to get it work but difficult to do it right."

The root of doing the right thing is completely relied on the fundamentals - knowledge of Software Engineering. This is a big topic that I would continue to write articles on it.

Let's back to the topic - The javascript declarative cost.

The javascript declarative cost

ES6 has introduced functional operators(.map, .filter, .reduce) for Array. I can't remember the last time that I wrote "for loop" control flow to compute data in javascript.

When I started to build a new project called kt.ts that targeting to bring a wise designed API interface from Kotlin to Typescript. I started with a wrapper approach that use lodash internally to build the kt.ts abstractions. After few hours of implementation and design the project architecture, few commonly used interfaces are finished like .mapNotNull(transform), .distinct().

In Javascript, ramda for example,

R.pipe(
  R.map(transform),
  R.filter(isNotNullPredicate),
  R.uniq,
  R.map(calculation),
  R.sum,
)(array)

In Kotlin, we could simply express in this away:

array
  .mapNotNull(transform)
  .distinct()
  .sumBy(calculation)

By then, I kick-started a project to benchmark my wrapped implementations. The result was unexpectedly slow compared with the vanilla .map + .filter approach and completely unacceptable.

The first thoughts came to my head was

Did I do something wrong? Is that V8 optimization too magical to me?
OMFG, there is a 80x performance difference!.

To solve this problem,

my sense of software engineering summon me to implement all possible versions of  .mapNotNull in vanilla javascript and different libraries.

The result is stunning that I had to group all my implementations into a single benchmark and design a scenario that would use .mapNotNull and .distinct to see if the difference would be deteriorating.

A Simple Benchmark

I implemented the same behavior in 20 variations using

  • native,
  • native-fp
  • lodash
  • lodash-fp
  • ramda
  • array extension (javascript prototype)
  • lazy sequence

The ramda and lodash/fp's benchmark results are surprisingly worse.

You could check the dedicated implementations at https://github.com/gaplo917/js-benchmark.

I am pretty sure that 80%+ of javascript world-wide usage won't process an array with size greater than 1k elements. Apart from lodash/fp and ramda, the vanilla functional implementation (native-fp), without the optimization, is quite bad either.

Accurate Benchmark on a Bare Metal in Packet

In order to get an accurate benchmark, we need to make sure the benchmark environment has no significant workload, no performance thermal throttling, and no affection with a noisy neighbor (if VM). That's why I have rented a packet bare metal machine (t1.small.x86) in NTR to perform the benchmark with a minimum of 1000 samples for each benchmark.

As a result, the whole benchmark 1000 sample * 20 implementations * 7 size takes 16.44 hours to run! With these findings, I created a simple javascript exercise in skill boost plan - https://github.com/gaplotech/SB03-js-declarative-cost to ignite other software engineers to learn more about the "declarative cost" by themselves.

I highly recommend sharing the SB03 exercise with your JS developer friends. Or Patreon to support my research :).

Conclusion

If a javascript developer had to implement a super efficient implementation, using lodash or ramda won't help at all.

They have to learn the necessary algorithms and data structures and write them in an imperative (control flow) style to get the best performance instead.

Learning lodash or ramda is just a way to work quickly and have better readability, but the implementation not work efficiently in javascript unless their internal implementations would eventually provide an "Parallel Processing" option that spawns an extra node process and process array elements in parallel.

Otherwise, under a single node process, you need to sacrifice some performance to trade readability and development speed. That is the "Declarative Cost".

If someone told you a single .map operator in lodash or ramda is just 20% slower than the native function operator, they really forgot about we normally chain 3 - 5 operators on average. That is

0.8^3=0.51 to 0.8^5=0.32, nearly 50-70% performance drop on average of reasonable array sizes.

Attachments

The attached PDF files contain a full benchmark result from array size 1,10,100,1k,10k,100k,1M.

all-log-scale-packet-bare-metal.pdf

by-size-packet-bare-meta.pdf