Blog post: Js_of_ocaml, a bundle size study

Hi all, I hope your Monday is going great. :slight_smile:

I wanted to analyze bundle size performance in Js_of_ocaml, so I rewrote an existing ReScript web app to compare both outputs.

Here is the blog post with all the data, conclusions, and takeaways:

It has been a very interesting experiment, that helped me learn more about Js_of_ocaml and the way it generates JavaScript code, and also improve some small things along the way in the libraries I was using for the project.

The conclusions, while maybe already known by others, are also quite exciting to me, as the experiment confirms my suspicion that Js_of_ocaml bundle size scales just fine as applications get more complex, so it is suitable for a quite significant number of real world scenarios.

I hope you find it interesting and exciting as well. Please share any feedback you might have! Or any questions if anything is unclear.

29 Likes

This seems to confirm my own experience: that the overhead of JSOO is fine for an application but that BuckleScript/ReScript/Melange produces markedly leaner output when publishing stand-alone libraries and components, especially the smaller ones.

I also like how I can use BS/RS/Melange’s FFI as “preprocessor macros” (kind of like C’s) in some cases. Here’s a clip from my experiments (annotation syntax may be outdated):

external _unsafe_get : 'a array -> int -> 'a = "%array_unsafe_get"
external _array_length : 'a array -> int = "%array_length"
let _iter f a =
  for i = 0 to _array_length a - 1 do
    f (_unsafe_get a i)
  done
  [@@inline]
;;
let del_all = _iter del
let empty element = children element |> _iter del

This made my DIY DOM API very expressive and yet produced the cutest JS ever:

function del_all(param) {
  for(var i = 0 ,i_finish = param.length; i < i_finish; ++i){
    del(param[i]);
  }
}

function empty(element) {
  var a = element.children;
  for(var i = 0 ,i_finish = a.length; i < i_finish; ++i){
    del(a[i]);
  }
}
5 Likes

Using / linking Js_of_ocaml.Js will force a dependency on printf (Js → Printexc → Printf) and adds ~50k to your bundle.

I’ve a branch of the ocaml compiler to remove the Printexc -> Printf dependency

5 Likes

That was interesting, thank you for sharing it. For the part about sets, JavaScript has a set in the standard library now: Set - JavaScript | MDN. Maybe using bindings to this instead of the OCaml or Rescript Set would help reduce bundle size? There are two caveats: it’s not immutable, and it doesn’t have all the methods from the OCaml or Belt/Rescript sets.

One other point that would be interesting would be a comparaison to this app made with other big players in the JS ecosystem: React, React with Typescript, Angular, maybe plain JS, JQuery. So that’s what I tried to do. The method was to clone the repo, run npm i, find how to get the webpack stats out depending on the framework for a production build, and launch webpack-bundle-analyzer on it. I’ll add some other when I have the time. All projects comes from the RealWorld website, category frontend, that you can see here.

I’m not sure if the counts are perfect, since Angular and React make different bundles from JSOO or ReScript. For Angular, I removed everything in the “vendor.js” file, and counted everything else. For React, I’ve removed everything in the stats/js/2.a9e8cf08.chunk.js file, and counted everything else. The reasoning is to remove the JS file that’s mainly composed of the node_modules folder.

Edit: I’ve tried it with Vue.js and a vanilla JS implementation. In the spirit of sharing results and saving other people from wasting time, I’ve noted when those have failed to build.

Technology, repo link Stat Parsed Gzipped Bundle link
ReScript, React 435.91 KB 60.14 KB 12.69 Kb bundle
js_of_ocaml, React 115.58 KB 110.34 KB 33.39 KB bundle
Angular 13 345.35 KB 453.3 KB 80.48 KB bundle
Angular 13 + NgRx + nx 304.1 KB 121.86 KB 40.92 KB bundle
React + Redux + Typescript 91.17 KB 48.99 KB 10.20 KB bundle
Vanilla JS + Web components - - - Failed to build
Vue.js, Vuex, axios, Typescript 118.82 KB 46.59 KB 16.62 KB bundle
Vue.js, Typescript - - - Failed to build
3 Likes

One more caveat: It uses JavaScript’s equality semantics.

1 Like

That’s a good point. It is the “classic JS equality” where primitives are compared by value, object by reference, except in some cases (IE mostly) where -0 and +0 are different. This makes me even more impatient for the record and tuples proposal since their equality works by value/content and not by reference/identity.

There are also immutable sets for JS. There’s one in the immutable.js library, a immutable-set package on npm, and you can also use Immer. Immutable.js is 17.6 Kb minified + gzipped according to bundlephobia, which doesn’t sound like a win compared to the functors. immutable-set is 867 bytes minified and gzipped on the same website. Immer is 5.6 Kb again on bundlephobia.

1 Like

This thread is the first time I learn that there is a code-size problem with CamlinternalFormat on Javascript backends. I think people should open an upstream issue to see whether it could be solved, instead of resorting to weird hacks or removing the feature altogether. I don’t know whether I should be surprised that no one thought of this, the communication between Javascript stuff and the upstream OCaml compiler is generally terrible.

(Note: it’s a bit ironic to write this in reply to @hhugo, who precisely is a js_of_ocaml dev putting a nice effort discussing things with upstream regularly. Oh well.)

4 Likes

Personally I didn’t know it was also due to CamlinternalFormat, when I looked into it when I was writing that piece of advice in brr's docs I tracked that down to existence of global mutable state in Format which js_of_ocaml can’t dead code away.

From the size of these bundles, I suspect these comparisons are not being accurate. In the blog post, I only considered the application code, not the 3rd party library code, as I don’t think the latter adds any value to the study. As an example, in both compared apps (ReScript and Js_of_ocaml), React.js is used, which adds more than 40kB to the final gzipped size.

If we consider all code, then the full JavaScript bundle of the Js_of_ocaml app case is ~99KB gzipped, which is actually larger than the React + Redux + TypeScript example shared above.

To make the right comparison, one should download webpack-bundle-analyzer and inspect the results for the other real world apps. As an example, here is the bundle map from the full Js_of_ocaml app, highlighting the part that is considered in the blog post. The interactive version is available here.

1 Like

I only realized the importance of CamlinternalFormat few month ago. See

Here is the 10 biggest stdlib modules once compiled to JS.

 54197 ./CamlinternalFormat.js
 19626 ./Stdlib__scanf.js
 16547 ./Stdlib__format.js
 11169 ./Stdlib__ephemeron.js
 10577 ./Stdlib__list.js
  8916 ./Stdlib__hashtbl.js
  8870 ./CamlinternalOO.js
  8753 ./Stdlib__set.js
  8318 ./Stdlib__arg.js
  8242 ./Stdlib__map.js

As you can see, CamlinternalFormat is a bit special.

3 Likes

Thanks for raising that point, I’ve used webpack-bundle-analyzer too but measured the whole size instead of only the size of the application. I’ll edit my post to correct that.

Edit: I’ve edited my original posts, and added a link to the bundle visualisations so people can see for themselves.

1 Like

I looked at CamlInternalFormat and its Javascript output. The result in fact seem rather natural: this module has more code than most other modules in the standard library, because we support many different formatting features (padding, conversion, runtime parsing of format strings, etc.).

$ (cd .../stdlib/ && wc -c *.ml | sort -n -r | head -n 10)
690517 total
124679 camlinternalFormat.ml
 54300 scanf.ml
 50750 format.ml
 29125 camlinternalFormatBasics.ml
 25948 bytes.ml
 22515 ephemeron.ml
 21871 stdlib.ml
 19469 set.ml
 19103 camlinternalOO.ml

Looking at the Javascript output, I believe that js_of_ocaml could save some space by optimizing pattern-matching for space. (I’m showing the non-minified outputs below, but the same things occur in the minified output, just with shorter variable names.)

(1) There are several cases of patterns that only return constants, and could be turned into a table lookup. For example:

switch(fconv[2])
       {case 0:return 102;
        case 1:return 101;
        case 2:return 69;
        case 3:return 103;
        case 4:return 71;
        case 5:return cF;
        case 6:return 104;
        case 7:return 72;
        default:return 70}

Sometimes only some of the cases are constant, or parts of the switch are affine, or a “constructor reuse” detection could notice a common opportunity to share code; these would require more sophisticated analyses in the compiler

switch(ty2[0])
 {case 10:break;
  case 11:switch$0 = 1;break;
  case 12:switch$0 = 2;break;
  case 13:switch$0 = 3;break;
  case 14:switch$0 = 4;break;
  case 8:switch$0 = 5;break;
  case 9:switch$0 = 6;break;
  default:throw [0,Assert_failure,_b_]}
switch(param[0])
 {case 0:var rest=param[1];return [0,symm(rest)];
  case 1:var rest$0=param[1];return [1,symm(rest$0)];
  case 2:var rest$1=param[1];return [2,symm(rest$1)];
  case 3:var rest$2=param[1];return [3,symm(rest$2)];
  case 4:var rest$3=param[1];return [4,symm(rest$3)];
  case 5:var rest$4=param[1];return [5,symm(rest$4)];
  case 6:var rest$5=param[1];return [6,symm(rest$5)];
  case 7:var rest$6=param[1];return [7,symm(rest$6)];
  case 8:var rest$7=param[2],ty=param[1];return [8,ty,symm(rest$7)];
  case 9:
   var rest$8=param[3],ty2=param[2],ty1=param[1];
   return [9,ty2,ty1,symm(rest$8)];
  case 10:var rest$9=param[1];return [10,symm(rest$9)];
  case 11:var rest$10=param[1];return [11,symm(rest$10)];
  case 12:var rest$11=param[1];return [12,symm(rest$11)];
  case 13:var rest$12=param[1];return [13,symm(rest$12)];
  default:var rest$13=param[1];return [14,symm(rest$13)]}}

(3) Sometimes the right-hand-sides of a pattern could be shared, but they are not shared.

switch(ign[0])
 {case 0:return type_ignored_param_one(ign,rest,fmtty);
  case 1:return type_ignored_param_one(ign,rest,fmtty);
  case 2:return type_ignored_param_one(ign,rest,fmtty);
  case 3:return type_ignored_param_one(ign,rest,fmtty);
  case 4:return type_ignored_param_one(ign,rest,fmtty);
  case 5:return type_ignored_param_one(ign,rest,fmtty);
  case 6:return type_ignored_param_one(ign,rest,fmtty);
  case 7:return type_ignored_param_one(ign,rest,fmtty);
  case 8:
   var sub_fmtty$2=ign[2],pad_opt$1=ign[1];
   return type_ignored_param_one
           ([8,pad_opt$1,sub_fmtty$2],rest,fmtty);
  case 9:
   var
    sub_fmtty$3=ign[2],
    pad_opt$2=ign[1],
    _dz_=type_ignored_format_substituti(sub_fmtty$3,rest,fmtty),
    match$35=_dz_[2],
    fmtty$22=match$35[2],
    fmt$22=match$35[1],
    sub_fmtty$4=_dz_[1];
   return [0,[23,[9,pad_opt$2,sub_fmtty$4],fmt$22],fmtty$22];
  case 10:return type_ignored_param_one(ign,rest,fmtty);
  default:return type_ignored_param_one(ign,rest,fmtty)}
switch(fmtty[0])
 {case 0:
   var rest=fmtty[1];
   return function(param){return make_from_fmtty(k,acc,rest,fmt)};
  case 1:
   var rest$0=fmtty[1];
   return function(param){return make_from_fmtty(k,acc,rest$0,fmt)};
  case 2:
   var rest$1=fmtty[1];
   return function(param){return make_from_fmtty(k,acc,rest$1,fmt)};
  case 3:
   var rest$2=fmtty[1];
   return function(param){return make_from_fmtty(k,acc,rest$2,fmt)};
  case 4:
   var rest$3=fmtty[1];
   return function(param){return make_from_fmtty(k,acc,rest$3,fmt)};
  [...]
}

(4) Some generated switch code is a bit weird, it defines unused variables and then uses continue.

switch(ign[0])
{case 0:var fmtty$0=rest$18;continue;
 case 1:var fmtty$0=rest$18;continue;
 case 2:var fmtty$0=rest$18;continue;
 case 3:var fmtty$0=rest$18;continue;
 case 4:var fmtty$0=rest$18;continue;
 case 5:var fmtty$0=rest$18;continue;
 case 6:var fmtty$0=rest$18;continue;
 case 7:var fmtty$0=rest$18;continue;
 case 8:var fmtty$0=rest$18;continue;
 case 9:
  var fmtty$5=ign[2],_dF_=fmtty_of_fmt(rest$18);
  return caml_call2(CamlinternalFormatBasics[1],fmtty$5,_dF_);
 case 10:var fmtty$0=rest$18;continue;
 default:var fmtty$0=rest$18;continue}

Some of these things could be done (or tuned) in the upstream compiler, the pattern-matcher could try harder to generate more compact code in some cases; or it could be changed in js_of_ocaml, which has its own analysis and re-optimization pipeline if I understand correctly.

This said, while there certainly would be some gains, the total reduction in size would probably be small, in the 5-15% range on this file.

3 Likes

If one wanted to have a compromise between “not supporting Printf at all” and the current code size, it may be possible to compromise by not-supporting some of the Printf feature. Some of the code size comes from more arcane features that are rarely used in practice; a feature-restricted Format could be twice smaller and many people wouldn’t notice the difference.

2 Likes

I did a small experiment removing %{ fmt %}, %( fmt %) and conversion between formats and runtime strings, and I get a 40% size reduction (from 56Kio to 36Kio) of minified js. (Edit: in fact 16Kio, so a 70% size reduction.)

9 Likes

Do you have an ocaml branch available somewhere ?

I pushed my quick hack to gasche-snippets / Format slimmer · GitLab

This is a sized-down version of camlinternalFormat that supports both Printf and Scanf (all of them), but using %{ fmt %} or %( fmt %) fails at runtime.

This is not a fork of ocaml/ocaml, because more work would be required if we wanted to have this in the compiler: the type-checker relies on some features that were removed (parsing of format string), so those would have to be moved somewhere else.

I just tracked down a “hidden” dependency on Printexc in an app I was working on.

It turns out that in the Fun module you have this bit:

let () = Printexc.register_printer @@ function
| Finally_raised exn -> Some ("Fun.Finally_raised: " ^ Printexc.to_string exn)
| _ -> None

So, just removing the use of the function from the Fun module reduced the size of my release bundle by ~56kb. Nice!

(I only say “hidden” as it wasn’t obvious to me that using one of the functions from Fun that wasn’t doing anything with exceptions would pull in the the exception printing machinery.)

5 Likes