Skip to content

Use ES 2015 syntax to generate more compact code for blocking functions. #1137

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 16, 2022

Conversation

nevkontakte
Copy link
Member

The idea is that we can avoid referencing each variable identifier multiple times in blocking functions intro and outro using ES2015 syntax sugar for object literals and descructuring assignments.

Consider following example:

package main

var c = make(chan string, 1)

func producer() {
	message := "hi"
	c <- message
}

func consumer() string {
	return <-c
}

func main() {
	go producer()
	println(consumer())
}

Generated code of the producer() function before this change:

	producer = function() {
		var message, $s, $r;
		/* */ $s = 0; var $f, $c = false; if (this !== undefined && this.$blk !== undefined) { $f = this; $c = true; message = $f.message; $s = $f.$s; $r = $f.$r; } s: while (true) { switch ($s) { case 0:
		message = "hi";
		$r = $send(c, message); /* */ $s = 1; case 1: if($c) { $c = false; $r = $r.$blk(); } if ($r && $r.$blk !== undefined) { break s; }
		$s = -1; return;
		/* */ } return; } if ($f === undefined) { $f = { $blk: producer }; } $f.message = message; $f.$s = $s; $f.$r = $r; return $f;
	};

We can see that the local variable message is referenced 3 times in lines 1-2 and twice on the last line.

Generated code after this change:

	producer = function() {
		var {message, $s, $r, $c, $f} = $restore(this, {});
		/* */ $s = $s || 0; s: while (true) { switch ($s) { case 0:
		message = "hi";
		$r = $send(c, message); /* */ $s = 1; case 1: if($c) { $c = false; $r = $r.$blk(); } if ($r && $r.$blk !== undefined) { break s; }
		$s = -1; return;
		/* */ } return; } if ($f === undefined) { $f = { $blk: producer }; } $f = {...$f, $r, message, $s};return $f;
	};

Local variable message is referenced only once on the line 1 and once more on the last line, plus overall the function intro code is a lot more compact.

Reference app: jQuery TodoMVC (acf500a6c32a83d8c4582d967b09a65febf0e120)

BRANCH ORIGINAL MINIFIED COMPRESSED (GZIP)
Pull request (es6-loads) 2,904,199 bytes 1,913,886 bytes 381,037 bytes
Target branch (master) 8.83% decrease (-281,161 bytes) 7.86% decrease (-163,201 bytes) 3.23% decrease (-12,722 bytes)

Updates #136.

This change deduplicates variable declaration with restoration from
saved context for blocking functions. This removes a significant source
of duplicated code in each blocking function intro.

We also introduce a helper function $restore to eliminate some
repetitive snippets of generated code.
@nevkontakte nevkontakte added this to the Output size reduction milestone Aug 7, 2022
@nevkontakte nevkontakte requested a review from flimzy August 7, 2022 13:09
@nevkontakte nevkontakte mentioned this pull request Aug 7, 2022
5 tasks
Copy link
Member

@flimzy flimzy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive size results!

Any idea if/how this affects performance of the executed code?

@nevkontakte
Copy link
Member Author

Good question, I assumed it should be more or less neutral, but the GoroutineSwitching benchmark shows a significant degradation:

$ benchstat /tmp/old.txt /tmp/new.txt
name                old time/op  new time/op  delta
GoroutineSwitching   356ns ± 3%  1218ns ± 2%  +241.96%  (p=0.000 n=10+8)

In real life it probably isn't so bad — our CI run time did not change much, but I'll look into that and see if we can improve it.

At some future point I was also considering reimplementing goroutines using ES 2015 generator functions, which should be even more compact and hopefully performant, but that would be a much bigger change.

@nevkontakte
Copy link
Member Author

After some investigation I was able to make this change more or less performance-neutral, and even push size reduction a bit further.

Here are some benchmarking results:

Env: Node.js 12 / WSL
name                old time/op  new time/op  delta
GoroutineSwitching   350ns ± 3%   343ns ± 3%  -1.84%  (p=0.004 n=10+9)
Env: Chrome 104 / Win10
name                old time/op  new time/op  delta
GoroutineSwitching   266ns ± 2%   253ns ± 4%  -4.85%  (p=0.000 n=9+10)
Env: Firefox 103 / Win10
name                old time/op  new time/op  delta
GoroutineSwitching   684ns ±14%   735ns ±29%   ~     (p=0.156 n=9+10)
Env: Edge 104 / Win 10
name                old time/op  new time/op  delta
GoroutineSwitching   251ns ± 2%   268ns ± 1%  +6.77%  (p=0.000 n=9+10)

A couple of caveats for the benchmark numbers:

  • Performance deltas for Node, Chrome and Edge are likely explained by variable background load on my PC. I didn't bother setting up clean benchmarking environment, so I would consider this neutral overall.
  • Firefox seems to show some decrease in performance, but it also shows very high variability in the performance numbers, to the point that benchstat calls the difference statistically insignificant. Unfortunately, its profiling tools lack the ability of per-line profile, so I was unable to pinpoint the difference.
BRANCH ORIGINAL MINIFIED COMPRESSED (GZIP)
Pull request (es6-loads) 2,877,935 bytes 1,895,863 bytes 380,638 bytes
Target branch (master) 9.65% decrease (-307,425 bytes) 8.72% decrease (-181,224 bytes) 3.33% decrease (-13,121 bytes)

@nevkontakte
Copy link
Member Author

Overall it's worth noting that the like with every synthetic benchmark, the real-world difference is likely to be much, much smaller. GoroutineSwitching benchmark was designed to cause goroutines block on each other as aggressively as possible, which is unlikely to happen in real-life code.

@nevkontakte
Copy link
Member Author

As a curious side note, gopherjs on Node 12 performs in this benchmark only two times slower than native go:

name                old time/op  new time/op  delta
GoroutineSwitching   157ns ± 0%   343ns ± 3%  +118.22%  (p=0.000 n=9+9)

Hat tip to V8 devs for that.

@paralin
Copy link
Contributor

paralin commented Aug 16, 2022

@nevkontakte thanks for your work on this and gopherjs in general. I can try to test this PR on a fairly complex project and see if it works. Unfortunately some packages I'm importing started using generics... Will try to see if I can help implement that perhaps.

@nevkontakte
Copy link
Member Author

Thanks @paralin, any additional testing is always welcome, although I'm fairly confident in this PR and was going to merge it as soon as I address a couple of minor comments.

If you'd like to help adding support for generics, it will be much appreciated. I've been doing some research on how it could be done in GopherJS context and will try to write that down #1013 today after work.

@tomconnell-wf
Copy link
Contributor

I'm a fan of this. Our app compiles to 5.6MB minified-and-gzipped, so even a 3% decrease in size is going to be at least 100-some kb smaller, which is significant.

Avoid use of the `{...context}` syntax, which appears to have a
significant performance penalty when used in a tight loop.

In addition, apply a few further size and performance optimizations:
skip unpacking $f from the context and instead fully construct it in the
function epilogue.
@nevkontakte nevkontakte enabled auto-merge August 16, 2022 21:19
@nevkontakte nevkontakte merged commit 2d12e51 into gopherjs:master Aug 16, 2022
@nevkontakte nevkontakte deleted the es6-loads branch August 16, 2022 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants