Re: Discovering which optimizations took place

Matthew Fluet <[email protected]>
Newsgroups gmane.comp.lang.ml.mlton.user
Message-ID <CAMrhFL5pZ+zXdi4DveaYgzdMVtSS=pucsejKyni5unmsH8GR3Q@mail.gmail.com>
On Wed, Nov 12, 2014 at 8:17 AM, Aggelos Biboudis <[email protected]> wrote:
> recently I developed/ported to SML, a push-based stream processing library
> just to investigate the performance characteristics of such a design under
> MLton. It was an opportunity to learn SML (sry in advance if you notice
> unidiomatic code) and use MLton for the first time (at last). I was amazed
> by the result, but I am more interested to learn about the optimizations
> that took place in this particular scenario.

Glad to hear that MLton performed positively with your streaming library.

> For example, in the flatMap/Map case (Cartesian product) did I essentially
> get nested loops?
>
> Can you provide me guidelines to further analyze the underlying code, of
> such an experiment?
> Which is the preferred way? x86 deep dive or AST-pretty printing somehow?

Generally, most of the significant optimizations are performed on the
SSA and SSA2 intermediate languages.  You can read more about the ILs
and the various optimizations passes at:
  http://mlton.org/SSA
  http://mlton.org/SSASimplify
  http://mlton.org/SSA2
  http://mlton.org/SSA2Simplify

You can keep the various intermediate languages with the "-keep <IL>"
command-line flag.  For instance:
  mlton -verbose 2 -keep core-ml -keep xml -keep sxml -keep ssa -keep
ssa2 -keep rssa -keep machine -keep g
will dump the program in each of the major intermediate languages (see
http://mlton.org/IntermediateLanguage) as well as the generated
assembly ("-keep g").

You can also keep the IL of the program before and after individual
optimizations with the "-keep-pass <RE>" command-line flag.  For
instance:
  mlton -verbose 2 -keep-pass introduceLoops1
will dump the program before and after the first "introduceLoops"
optimization pass.  One word of warning when looking at the .pre.ssa
vs. the .post.ssa files: many of the SSA optimizations effectively
rewrite the program using a "rev-map" over the collection of
functions; this is because the order of functions in the program IL
does not matter and a rev-map is tail-recursive (whereas a map is
either not tail-recursive or performs an extra reverse).  However, the
consequence is that a textual diff of .pre.ssa vs. .post.ssa is often
not particularly helpful to identify what changed.

The "-keep-pass <RE>" command-line flag takes a regular-expression, so
"-keep-pass '.*'" will effectively dump the program before and after
every optimization and translation pass.

For the SSA and SSA2 ILs, it is often convenient to see the
control-flow graph representation of the individual functions; the
"-keep dot" command-line flag causes any dumped SSA/SSA2/RSSA IL
program to also dump Graphviz .dot files for the interprocedural
call-graph and for each of the individual functions.  Sometimes these
control-flow graphs can be fairly large, but I've had good luck using
Graphiviz's dot tool to convert them to SVG format and viewing them in
a recent web-browser.

The "-drop-pass <RE>" command-line flag cab be used to skip a
particular optimization pass.  This can be useful for seeing the
impact of a particular optimization pass on a benchmark.  Of course,
it isn't always easy to isolate the benefit of individual passes;
often, the benefit is that it enables other optimization passes.


With regards to your specific benchmark program, I did the following:
  make FLAGS="-verbose 2 -keep ssa -keep dot" bench
  dot -Tsvg bench-streams.ssa.gen_0.cfg.dot >
bench-streams.ssa.gen_0.cfg.dot.svg
I believe that the "gen_0" SSA IL function corresponds to the "cart"
function in bench-streams.sml.  The name "gen" comes from your
"Streams.ofArray" implementation and the "Stream.sum",
"Stream.flatMap", and "Stream.map" code has been inlined and is being
driven by the source stream --- which I think is precisely what you
want for a push-based stream library.

Note that MLton has a non-trivial implementation of "structure
LargeInt" (see http://mlton.org/MLtonIntInf).  So, while you can find
the "IntInf_mul" that corresponds to the "x * y" in the inner
"Stream.map" and the "IntInf_add" that corresponds to the "a + ss" in
the "Stream.sum", there is also a lot of supporting code that
determines whether the operation can be done at a "small"
fixed-precision or if it needs to be done at a "big"
arbitrary-precision.  You might find it easier to investigate the
exact form of the optimized program by switching from LargeInt to Int
(or to Word, if you don't really care about the arithmetic result,
since Word operations will silently wrap and not raise Overflow).

If you look at the "gen_0" SSA IL function, you will see a non-tail
recursive call of "gen_0" (which gives rise to a nested
interprocedural loop) and a number of nested intraprocedural loops.  I
would think that the non-tail recursive call corresponds to the
non-tail call of "innerf(iterf)" in the "Stream.flatMap"
implementation.  But, there isn't an unbounded stack growth:

[mtf@fenrir sml-streams]$ git diff
diff --git a/benchmarks/bench-streams.sml b/benchmarks/bench-streams.sml
index f82cc9c..deb4d02 100644
--- a/benchmarks/bench-streams.sml
+++ b/benchmarks/bench-streams.sml
@@ -67,8 +67,8 @@ fun main () =

        (* Backing Arrays *)
        val backingArr = Array.tabulate (3000000, fn i => i);
-       val backingArrCart1 = Array.tabulate (100, fn i => Int.toLarge i);
-       val backingArrCart2 = Array.tabulate (10, fn i => Int.toLarge i);
+       val backingArrCart1 = Array.tabulate (10000000, fn i => Int.toLarge i);
+       val backingArrCart2 = Array.tabulate (100, fn i => Int.toLarge i);

        (* Stream wrapping *)
        val v = Stream.ofArray backingArr;
[mtf@fenrir sml-streams]$ make bench
make dirs
mkdir -p "/Users/mtf/tmp/sml-streams/build"
Type checking streams.
"mlton" -verbose 0 -stop tc /Users/mtf/tmp/sml-streams/streams.mlb
make dirs
mkdir -p "/Users/mtf/tmp/sml-streams/build"
Building bench-streams.
"mlton" -verbose 0 -output
/Users/mtf/tmp/sml-streams/build/bench-streams
/Users/mtf/tmp/sml-streams/benchmarks/bench-streams.mlb
[mtf@fenrir sml-streams]$ ./build/bench-streams @MLton gc-summary --
Running
Streams filters_6 time: 0.011 sec/op
Baseline filters_6 time: 0.011 sec/op
Streams cart time: 10.895 sec/op
Baseline cart time: 7.897 sec/op

Validation
Streams  filters_6 = 2999984
Baseline filters_6 = 2999984
Streams  cart     = 247499975250000000
Baseline cart     = 247499975250000000
GC type time ms number  bytes      bytes/sec
------------- ------- ------- --------------- ---------------
copying     50      2     80,031,032  1,600,620,640
mark-compact      0      0              0              -
minor      0      0              0              -
total time: 18,883 ms
total GC time: 117 ms (0.6%)
max pause time: 116 ms
total bytes allocated: 412,022,616 bytes
max bytes live: 80,016,176 bytes
max heap size: 640,196,608 bytes
max stack size: 848 bytes
num cards marked: 0
bytes scanned: 0 bytes
bytes hash consed: 0 bytes

>From the "max stack size: 848 bytes", you can see that that non-tail
recursive call is not repeated.  What I think you are seeing with the
non-tail recursive call is that the same "gen_0" function is being
used to drive the "Stream.map" on the v2 and the "Stream.flatMap" on
the v1, where a defunctionalized closure ("datatype lambdas_1 = ... "
in bench-streams.ssa) is being used to distinguish the two uses.

Looking carefully at that defunctionalized closure:
  lambdas_1 = Env_5 of (intInf array, (word64 * word64), lambdas_1) |
Env_4 of (intInf, (word64 * word64), lambdas_1) | Env_3 of ((word64 *
word64))
we recognize Env_5 as the argument to Stream.flatMap (the "intInf
array" is the "v2'" free variable) and Env_4 as the argument to
Stream.map (the "intInf" is the "x" free variable).  More importantly,
there is only one Env_5 construction in the program, which takes the
one Env_3 construction as an argument.  Furthermore, all of the Env_4
constructions are constructed with a lambdas_1 taken from a
deconstructed Env_5.  So, the lambdas_1 component of all Env_5 and
Env_4 values in the program are an Env_3.  Furthermore, the non-tail
recursive call of "gen_0" is reachable only by deconstructing a Env_5,
which convinces us that there is only ever two "gen_0" frames on the
call stack.  If we were to unroll "gen_0" once and exploit this fact
about the lambdas_1 component of Env_4 and Env_5 constructors, then we
would achieve entirely intraprocedural loops with no non-tail
recursive calls.

While we wait for the compiler to learn that optimization, we can
verify that things would be better if we didn't conflate the driving
of the "Stream.map" on the v2 and the "Stream.flatMap" on the v1.  To
do so, make a code-clone of "Stream.ofArray" and use one for v1 and
the other for v2:

[mtf@fenrir sml-streams]$ git diff
diff --git a/benchmarks/bench-streams.sml b/benchmarks/bench-streams.sml
index f82cc9c..9b9b83b 100644
--- a/benchmarks/bench-streams.sml
+++ b/benchmarks/bench-streams.sml
@@ -67,13 +67,13 @@ fun main () =

        (* Backing Arrays *)
        val backingArr = Array.tabulate (3000000, fn i => i);
-       val backingArrCart1 = Array.tabulate (100, fn i => Int.toLarge i);
-       val backingArrCart2 = Array.tabulate (10, fn i => Int.toLarge i);
+       val backingArrCart1 = Array.tabulate (10000000, fn i => Int.toLarge i);
+       val backingArrCart2 = Array.tabulate (100, fn i => Int.toLarge i);

        (* Stream wrapping *)
        val v = Stream.ofArray backingArr;
        val v1 = Stream.ofArray backingArrCart1;
-       val v2 = Stream.ofArray backingArrCart2;
+       val v2 = Stream.ofArray2 backingArrCart2;

        (* Stream functions *)
        fun length values = Stream.length values;
diff --git a/streams.sig b/streams.sig
index c4c0426..2720c0b 100644
--- a/streams.sig
+++ b/streams.sig
@@ -5,6 +5,7 @@
 signature STREAM = sig
     type 't stream
     val ofArray : 't array -> 't stream
+    val ofArray2 : 't array -> 't stream
     val map    : ('t -> 'r) -> 't stream -> 'r stream
     val filter : ('t -> bool) -> 't stream -> 't stream
     val takeWhile : ('t -> bool) -> 't stream -> 't stream
diff --git a/streams.sml b/streams.sml
index 5b2e5ba..3e9b9df 100644
--- a/streams.sml
+++ b/streams.sml
@@ -100,4 +100,21 @@ structure Stream : STREAM = struct
       in
          Stream gen
       end
+
+  fun ofArray2 arr =
+      let val gen =
+          fn iterf =>
+             let
+                 val counter = ref 0
+                 val cont = ref true
+                 val size = Array.length arr
+             in
+                 while !counter < size andalso !cont do (
+                     cont := iterf (Array.sub(arr, !counter));
+                     counter := !counter + 1
+                 )
+             end
+      in
+         Stream gen
+      end
 end
[mtf@fenrir sml-streams]$ make bench
make dirs
mkdir -p "/Users/mtf/tmp/sml-streams/build"
Type checking streams.
"mlton" -verbose 0 -stop tc /Users/mtf/tmp/sml-streams/streams.mlb
make dirs
mkdir -p "/Users/mtf/tmp/sml-streams/build"
Building bench-streams.
"mlton" -verbose 0 -output
/Users/mtf/tmp/sml-streams/build/bench-streams
/Users/mtf/tmp/sml-streams/benchmarks/bench-streams.mlb
[mtf@fenrir sml-streams]$ ./build/bench-streams
Running
Streams filters_6 time: 0.011 sec/op
Baseline filters_6 time: 0.011 sec/op
Streams cart time: 7.269 sec/op
Baseline cart time: 7.700 sec/op

Validation
Streams  filters_6 = 2999984
Baseline filters_6 = 2999984
Streams  cart     = 247499975250000000
Baseline cart     = 247499975250000000

Looking at bench-streams.ssa for this version of the program, you'll
see that there is no "gen_0" function -- the corresponding code is no
non-recursive and has been inlined into the "main_0" function.  The
nested loops should be there, but somewhat lost amid all of the
top-level initialization and exception handling code.  In any case,
you get an improved throughput.

To unsubscribe from this group and stop receiving emails from it, send an email to [email protected].

------------------------------------------------------------------------------
Comprehensive Server Monitoring with Site24x7.
Monitor 10 servers for $9/Month.
Get alerted through email, SMS, voice calls or mobile push notifications.
Take corrective actions from your mobile device.
http://pubads.g.doubleclick.net/gampad/clk?id=154624111&iu=/4140/ostg.clktrk
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.