Skip to content

Use a Javascript Map for Go Maps. Improves performance of len() calls by orders of magnitude. 🗺️ #1136

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 1 commit into from
Aug 24, 2022

Conversation

tomconnell-wf
Copy link
Contributor

@tomconnell-wf tomconnell-wf commented Aug 5, 2022

Overview

#1135

The performance of len() on maps brought me here, because it would call js Object.keys(myMap).Length. It was many orders of magnitude slower than len() in Go. It was a pitfall that those writing idiomatic go would fall into.

This PR switches the backing implementation of Maps in GopherJS to ECMAScript 2015 Maps. These maps provide accounting so that Map.size can be called to make len() fast. I was hopeful that iterating maps would be faster, also, because the len of the map is used, but unfortunately that is not the case. However, it should be trivial to change the loop implementation to use [Map.entries()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries) when GopherJS adopts ECMAScript 2017.

One thing to note: ES 2015 Maps do not return in random order. There is a pitfall, the I believe is acceptable, that someone could write GopherJS code that relies on the order of elements in a map, that does not work when run in the Go runtime.

Using the benchmarks committed as part of this PR, with a size of 10000 elements:
Master

✗ GOOS=linux gopherjs test ./... --bench="Benchmark.*"
Using GOOS=linux and GOARCH=ecmascript in GopherJS is deprecated and will be removed in future. Use GOOS=js GOARCH=ecmascript instead.
goos: js
goarch: ecmascript
BenchmarkSliceLen           	1000000000	         0.2600 ns/op
BenchmarkMapLen             	    9886	    115719 ns/op
BenchmarkMapNilCheck        	1000000000	         0.5070 ns/op
BenchmarkMapNilElementCheck 	144578312	         8.328 ns/op
BenchmarkSliceRange         	  226414	      5070 ns/op
BenchmarkMapRange           	    6315	    183215 ns/op
PASS
ok  	benchmark/tom	7.682s

This Branch
Map len is ~6 orders of magnitude faster. Unfortunately, it appears element access and ranging has gotten ~20% worse, and ranging ~3% worse

✗ GOOS=linux gopherjs test ./... --bench="Benchmark.*"
Using GOOS=linux and GOARCH=ecmascript in GopherJS is deprecated and will be removed in future. Use GOOS=js GOARCH=ecmascript instead.
goos: js
goarch: ecmascript
BenchmarkSliceLen           	1000000000	         0.2820 ns/op
BenchmarkMapLen             	1000000000	         0.5100 ns/op
BenchmarkMapNilCheck        	1000000000	         0.2610 ns/op
BenchmarkMapNilElementCheck 	104008665	        10.50 ns/op
BenchmarkSliceRange         	  222222	      5094 ns/op
BenchmarkMapRange           	    6082	    188096 ns/op
PASS
ok benchmark/tom	7.001s

Testing

It seems like the tests cover many more cases than manual testing ever could. I may have tunnel vision as the author of the PR, but I believe that passing CI tests should be considered good enough.

@tomconnell-wf
Copy link
Contributor Author

tomconnell-wf commented Aug 5, 2022

At this point, maps cannot be accessed by index, but their size can be acquired, and they can be iterated.

Using the same benchmark I used to report the slowness (with 1000 entries):

goos: linux
goarch: js
BenchmarkSliceLen           	1000000000	         0.2530 ns/op
BenchmarkMapLen             	1000000000	         0.5040 ns/op
BenchmarkMapNilCheck        	1000000000	         0.5050 ns/op
BenchmarkMapNilElementCheck 	144578312	         8.300 ns/op
PASS

The len check is as quick as I would hope. (The setup of the map is very slow, and could possibly be improved (Because I'm making a slice of maps that then get converted in $makeMap, but I don't think that is any different than master.)

@tomconnell-wf
Copy link
Contributor Author

tomconnell-wf commented Aug 5, 2022

FWIW, this is with 1 million elements

goos: linux
goarch: js
BenchmarkSliceLen           	1000000000	         0.2560 ns/op
BenchmarkMapLen             	1000000000	         0.5920 ns/op
BenchmarkMapNilCheck        	1000000000	         0.6890 ns/op
BenchmarkMapNilElementCheck 	98797087	        10.13 ns/op

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.

Thanks for working on this! Looks quite promising. I look forward to seeing the rest of the implementation.

@tomconnell-wf
Copy link
Contributor Author

Well, I think I am almost there. It looks like I have a Sprintf problem to fix, and then done?

@tomconnell-wf
Copy link
Contributor Author

As you can see in CI, there are some fmt stdlib tests that are failing. It seems like it is because I didn't wire something up to reflect/reflectlite properly.

Here's one of the tests that are failing.
https://cs.opensource.google/go/go/+/master:src/fmt/example_test.go;l=295-300?q=dach&ss=go%2Fgo

The expected output is

map[dachshund:false peanut:true] map[string]bool{"dachshund":false, "peanut":true}

The incorrect output is

map[] map[string]bool{}

I'm a bit out of my depth here, but it seems like this is because the keys and values aren't reflecting properly. My gut says it has something to do with the change from e to e.v in https://github.com/gopherjs/gopherjs/pull/1136/files#diff-70e5ca5fb5a75cb3a234c5a2caee3036426e4ffbb46a9e6b2ee556cb244eccbcR623. I'll keep taking cracks at it, but if the maintainers have a hunch, I could use the guidance.

@tomconnell-wf
Copy link
Contributor Author

Or maybe it has something to do with $internalize and $externalize...?

@tomconnell-wf
Copy link
Contributor Author

I'm going with the theory that by doing this I clobbered the information needed for reflection to work properly. I'll massage it so that we can use the Map as a POJSO, with the reflection type info...I think.

@nevkontakte
Copy link
Member

@tomconnell-wf I've looked through the diff and there is nothing obviously incorrect in it I could pin the test failure on. My best guess is that somewhere in the reflect package we read the $keys().length value, which is now 0, and so iteration is not happening properly. I am not sure I will have time for a deep dive until the weekend, but if you don't find the bug until then, I'll give it a shot.

As a side note, I recommend you rebasing on top of the latest master, which includes Go 1.18 support. There have been some additions to the reflect map-related APIs in 1.18 which may be impacted by your change or will cause merge conflicts when you are ready.

@flimzy
Copy link
Member

flimzy commented Aug 10, 2022

I think the official recommendation it to create a source tools.go somewhere

We can go one step further, and put it in a subdir so that its in its own package that is never even parsed during compilation (but is parsed by go mod)

@tomconnell-wf
Copy link
Contributor Author

This may be considered phoning it in, but I can't figure out the reflection. As best I can understand, it's built around the keys and the elements being available on the javascript object. Rather than having a special case for Map types, and since these are all references anyway, I am thinking of leaving the key and element as they were before, but also putting them into the javascript Map api, for faster size calls. I will push commits for this later today, and some javascript heap snapshots to make sure memory usage isn't significantly higher.

@nevkontakte
Copy link
Member

I would strongly prefer not to do this, not for performance, but for maintenance reasons. A lot of our work on gopherjs over the past year has been dedicated to simplifying and documenting the codebase, and I would very much prefer to help you figure out the reflection side of the problem over adding more cruft that will be confusing people in future.

@tomconnell-wf
Copy link
Contributor Author

tomconnell-wf commented Aug 10, 2022

Side note - when it is decided that ECMA Script 2017 is supported, we can use .entries() instead of .keys() on the map range statement, and it'll be about 2x faster.
cf9a8fa

10000 elements:

Before
✗ GOOS=linux gopherjs test ./... --bench="Benchmark.*"
Using GOOS=linux and GOARCH=ecmascript in GopherJS is deprecated and will be removed in future. Use GOOS=js GOARCH=ecmascript instead.
goos: js
goarch: ecmascript
BenchmarkSliceLen 1000000000 0.2520 ns/op
BenchmarkMapLen 1000000000 0.2490 ns/op
BenchmarkMapNilCheck 1000000000 0.5010 ns/op
BenchmarkMapNilElementCheck 363636363 3.311 ns/op
BenchmarkSliceRange 222222 4991 ns/op
BenchmarkMapRange 5454 186285 ns/op
PASS

After
✗ GOOS=linux gopherjs test ./... --bench="Benchmark.*"
Using GOOS=linux and GOARCH=ecmascript in GopherJS is deprecated and will be removed in future. Use GOOS=js GOARCH=ecmascript instead.
goos: js
goarch: ecmascript
BenchmarkSliceLen 1000000000 0.2440 ns/op
BenchmarkMapLen 1000000000 0.2410 ns/op
BenchmarkMapNilCheck 1000000000 0.4830 ns/op
BenchmarkMapNilElementCheck 377358489 3.246 ns/op
BenchmarkSliceRange 222222 4847 ns/op
BenchmarkMapRange 13656 83553 ns/op
PASS

When this merges, I can make an issue. Making it now would be putting the cart before the horse.

@tomconnell-wf
Copy link
Contributor Author

tomconnell-wf commented Aug 10, 2022

I would strongly prefer not to do this, not for performance, but for maintenance reasons

I spent most of the day yesterday trying to figure it out. I'll give it another shot. Maybe having slept on it will unlock something.

@tomconnell-wf
Copy link
Contributor Author

tomconnell-wf commented Aug 10, 2022

Current state of performance. Range is a bit worse?!? :(

10000 elements

Master:

✗ GOOS=linux gopherjs test ./... --bench="Benchmark.*"
Using GOOS=linux and GOARCH=ecmascript in GopherJS is deprecated and will be removed in future. Use GOOS=js GOARCH=ecmascript instead.
goos: js
goarch: ecmascript
BenchmarkSliceLen           	1000000000	         0.2500 ns/op
BenchmarkMapLen             	   10774	    107203 ns/op
BenchmarkMapNilCheck        	1000000000	         0.4900 ns/op
BenchmarkMapNilElementCheck 	150753768	         8.026 ns/op
BenchmarkSliceRange         	  230768	      4853 ns/op
BenchmarkMapRange           	    6315	    164054 ns/op
PASS
ok  	github.com/Workiva/doc_plat_client/tool/internal/gopherjsbenchmarks/benchmark/tom	7.427s

This commit:

✗ GOOS=linux gopherjs test ./... --bench="Benchmark.*"
Using GOOS=linux and GOARCH=ecmascript in GopherJS is deprecated and will be removed in future. Use GOOS=js GOARCH=ecmascript instead.
goos: js
goarch: ecmascript
BenchmarkSliceLen           	1000000000	         0.2440 ns/op
BenchmarkMapLen             	1000000000	         0.2430 ns/op
BenchmarkMapNilCheck        	1000000000	         0.4840 ns/op
BenchmarkMapNilElementCheck 	357142856	         3.214 ns/op // Huh, did I make indexing faster?
BenchmarkSliceRange         	  218181	      4968 ns/op
BenchmarkMapRange           	    6458	    183958 ns/op
PASS
ok  	github.com/Workiva/doc_plat_client/tool/internal/gopherjsbenchmarks/benchmark/tom	6.549s

@tomconnell-wf
Copy link
Contributor Author

Those numbers make me think I could change tack and just account for the size of the map during creating, assign and delete. That seems semantically less correct, through. 🤔

@nevkontakte
Copy link
Member

nevkontakte commented Aug 10, 2022

I haven't tested it, but I'm pretty sure this is the line you need to modify to make reflect work:

keys: js.Global.Call("$keys", js.InternalObject(m)),

it probably should be something like keys: js.InternalObject(m).Call("keys"),.

return js.Global.Call("$keys", js.InternalObject(m)).Length()
and
return js.Global.Call("$keys", v.object()).Length()
are two other places.

@tomconnell-wf
Copy link
Contributor Author

Is there something special I need to do to include reflect changes? If I change reflect.go or reflectlite.go, then go install and then build/serve with gopherjs, the javascript output doesn't include my changes. This may be what I have been struggling with today and yesterday.

@flimzy
Copy link
Member

flimzy commented Aug 10, 2022

Is there something special I need to do to include reflect changes?

I believe this is probably what you're looking for: https://github.com/gopherjs/gopherjs/wiki/Developer-Guidelines#code-generation

@tomconnell-wf
Copy link
Contributor Author

Yeah, I just discovered that vfsgendev wasn't working right. 🤦🤦🤦

@tomconnell-wf
Copy link
Contributor Author

tomconnell-wf commented Aug 10, 2022

That'll make things easier, I think I'm getting some traction, now.

@tomconnell-wf
Copy link
Contributor Author

I don't really understand the whole keysFor construct, but I figure that's been because I've been dealing with strings and ints; probably needed for reliable keys for structs and such?

Anyway, now I can see the failing fmt test mapiter shape, I think I can fix it up.
Master
image

This branch
image

@tomconnell-wf
Copy link
Contributor Author

I hope CI doesn't cost much. I think it may actually pass now.

➜  gopherjs git:(javascript_map_impl) ✗ gopherjs test  --short fmt log os ./tests

PASS
ok  	fmt	1.170s
PASS
ok  	log	0.444s
PASS
ok  	os	1.447s
PASS
ok  	github.com/gopherjs/gopherjs/tests	6.368s

@tomconnell-wf
Copy link
Contributor Author

And, makes sense that we should be cautious with this.

@tomconnell-wf tomconnell-wf force-pushed the javascript_map_impl branch 2 times, most recently from 2da42db to 17eb766 Compare August 16, 2022 18:16
@tomconnell-wf
Copy link
Contributor Author

My curiosity is afire wanting to know what the one potential issue might be. 🐱

@nevkontakte
Copy link
Member

One other potential source of corner cases we need to verify is map internalization/externalization (that is, conversions between js.Object and Go native types). I would start by checking that mappings at the top of https://pkg.go.dev/github.com/gopherjs/gopherjs/js still hold. Then there is also the matter of embedding js.Object into structs:

type S structs {
  *js.Object
  Prop map[string]int `js:prop`
}

@tomconnell-wf
Copy link
Contributor Author

tomconnell-wf commented Aug 16, 2022

Good call, I'll see if I can make a test that can demonstrate correctness there.

@tomconnell-wf
Copy link
Contributor Author

One other potential source of corner cases we need to verify is map internalization/externalization

What is the expectation on wrapping? It seems to me that we would still want the new map implementation to wrap to a js.Object with map keys as properties, otherwise existing code will break. In other words:

type myStruct struct {
  map[string]string myMap
}

ms := &myStruct{map[string]string{`key`: `value`}}

wrapper := js.MakeFullWrapper(ms)

// Old Behavior - I think we should keep
wrapper.Get(`key`) // value

// Use Map API, breaking change
wrapper.Call(`get`, `key`) // value

@nevkontakte
Copy link
Member

Yes, I think we should make sure this doesn't break existing code and that Go maps are correctly translated into objects and back.

@tomconnell-wf
Copy link
Contributor Author

How do those tests look to demonstrate correctness of wrapping/internalize/externalize?

@tomconnell-wf
Copy link
Contributor Author

Oh, node doesn't like that. I'm not much of a Node guy. I wonder how best to do that namespacing for both contexts.

@nevkontakte
Copy link
Member

@tomconnell-wf see my suggestion here :)

@tomconnell-wf
Copy link
Contributor Author

Fwiw, I transpiled all of our rendering code with this branch, and it seemed to operate and output normally.

@tomconnell-wf
Copy link
Contributor Author

How are you all feeling about this? Is there anything additional I can do to increase comfort levels?

@tomconnell-wf
Copy link
Contributor Author

I can squash again when this is close to merge

@nevkontakte
Copy link
Member

@tomconnell-wf thanks, I left a few more comments. Please give me a day or two to rack my brain for more corner cases, but I agree that it is dangerously close to being merge-ready 😉

@tomconnell-wf
Copy link
Contributor Author

You're cooking up some good things to investigate. I'm not particularly in a rush. Now that the 1.18.5 tag is out, I will be busy getting upgraded for a while.

@tomconnell-wf
Copy link
Contributor Author

Any brainstorms over the weekend about edge case problems?

Copy link
Member

@nevkontakte nevkontakte left a comment

Choose a reason for hiding this comment

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

Sorry for the delay, my day job has kept my attention yesterday...

I couldn't think of any additional corner cases, so hopefully we didn't miss anything too bad :) I left what is hopefully the last round of comments, most of which are style-related. Other than that we need to:

  • Rebase the PR on top of 1.18 version of master (if you haven't already).
  • Fix CI failures (currently I can see that the gopherjs_tests workflow is failing).
  • Squash commits into one with an informative commit description.

And that should be it! 🎉 Thanks again for your hard work and patience with my nitpicks 😅

@tomconnell-wf
Copy link
Contributor Author

I agree with all of your suggestions. I'll get on that today.

@tomconnell-wf tomconnell-wf force-pushed the javascript_map_impl branch 2 times, most recently from 87e37c7 to 7bec2d2 Compare August 24, 2022 18:48
@tomconnell-wf
Copy link
Contributor Author

Okay, I think that's everything. I'll be happy to assist if any bugs come up with this in the near future.

The main motivation for this change is to improve the performance of
len() on maps.  Previously, len() would compile to javascript that
called `Object.keys().length`.  This iterates the whole map to get its
length.

Using the benchmarks added in this commit, on a map with 10000 elements:

```
BenchmarkMapLen             	    9886	    115719 ns/op
```

Now, len() just calls to javascript Map API, Map.size, which now as fast as calling len() on a slice.

```
BenchmarkMapLen             	1000000000	         0.5100 ns/op
```

This pull request contains changes to the compiler, prelude and standard
library necessary to utilize the new API. However, when externalizing
maps, they are still converted into objects to maintain backwards
compatibility.
@nevkontakte
Copy link
Member

All looks good to me. I've edited the commit message to exclude irrelevant bits, but I think as soon as CI completes, we are good to merge \o/

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.

3 participants