Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/ctdio/node-golang-native-addon-experiment

Experimenting with Go's cgo lib and Node native addons
https://github.com/ctdio/node-golang-native-addon-experiment

Last synced: 14 days ago
JSON representation

Experimenting with Go's cgo lib and Node native addons

Awesome Lists containing this project

README

        

# node-golang-native-addon-experiment

This is a (very) simple experiment with writing native node modules with [Golang](https://golang.org)
and [node-gyp](https://github.com/node/node-gyp).

### Dependencies

To try this out, you will need to have `Go` and `node-gyp` installed. Of course, you will also need
`Node.js` installed.

### How it works

To start, we first take the a simple Go program that exposes functions via [cgo](https://golang.org/cmd/cgo/).
Using the `C` package, we can utilize the special `export` comment to tell the compiler that a function is going
to be exported.

**`lib/main.go`**
```go
package main

import "C"

//export Hello
func Hello () *C.char {
return C.CString("Hello world!")
}

// required to build
func main () {}
```

**Note:** As you may have noticed, the function returns a C string instead of a regular Go string.
You can export a Go string, but this was a little easier to work with in the native code.

Now, we can build a shared library. We will export the shared object file as `libgo.so`. Doing this will
also export a `libgo.h` file, which we will need.

```bash
# in the lib directory
$ go build -buildmode=c-archive -o libgo.a
```

We build with the `c-archive` build mode because it links the library at compile time. Another option would be
to use the `c-shared` build mode, but depending on the platform, you may run into issues when attempting to run
the final script.

If you are adventurous and want to try out the `c-shared` build mode and happen to be using MacOS, you
might need to set the `DYLD_FALLBACK_LIBRARY_PATH` environment variable to include the project's `lib` directory.

Now, lets create the native code that will bridge our Go and Javascript.

**`goAddon.cc`**

```c++
#include
// include the header file generated from the Go build
#include "lib/libgo.h"

using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;

void HelloMethod (const FunctionCallbackInfo &args) {
Isolate *isolate = args.GetIsolate();
// Call exported Go function, which returns a C string
char *c = Hello();
// return the value
args.GetReturnValue().Set(String::NewFromUtf8(isolate, c));
delete c;
}

// add method to exports
void Init (Local exports) {
NODE_SET_METHOD(exports, "hello", HelloMethod);
}

// create module
NODE_MODULE(myGoAddon, init)
```

As you can see, we can simply import the correct header and call the exported `Hello` function.

Now that we have all of the native components ready, we can link everything together in our `binding.gyp` file.

**`binding.gyp`**
```gyp
{
'targets': [
{
'target_name': 'go-addon',
# import all necessary source files
'sources': [
'lib/libgo.h', # this file was generated by go build
'go-addon.cc'
],
# libraries are relative to the 'build' directory
'libraries': [ '../lib/libgo.a' ] # this file was also generated by go build
}
]
}
```

Now that we have all of the native portions in place, we just need to compile our native code into something
that `node` can use.

```bash
# Generate appropriate build files for platform
$ node-gyp configure

# build the project to create our bindings file
$ node-gyp build
```

After this, you should see that that a `build` directory was created. If you take a peek into the directory,
you will see that we now have a `go-addon.node` file under the `build/Release` folder.

Finally, we can write some Javascript.

**`index.js`**
```js
const goAddon = require('./build/Release/go-addon');

console.log(goAddon.hello());
```

Now, we can run our script and see it output the string that our Go function returned.

```bash
$ node index.js
Hello world!
```

If you want to see in action, but don't want to go through the trouble of running the commands, you can `cd` into the
`helloworld` directory and run the provided `run-build.sh` script to get a working build.

```bash
$ ./run-build.sh
```

### Benchmarks

It is well known that there is quite a bit of overhead when switching context between Go and C with `cgo`, but
is it enough deter devs from writing native modules with Go?

To start let's try running some benchmarks to see if the overhead has noticable effect on performance.

First, we will start with a simple test to see if there is much of a difference when it comes to invoking simple
functions. Here are the functions that were invoked in each language for the test:

```js
function jsAdd (a, b) {
return a + b;
}
```

```c++
void CppAdd (const FunctionCallbackInfo &args) {
Isolate *isolate = args.GetIsolate();
bool valid = validateArgs(isolate, args);
if (!valid) {
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Wrong args")));
return;
}

double sum = args[0]->NumberValue() + args[1]->NumberValue();
Local value = Number::New(isolate, sum);
args.GetReturnValue().Set(value);
}
```

```go
//export Add
func Add (a, b float64) float64 {
return a + b
}
```

let's not forget the glue that is needed for the Go function to be invoked.

```c++
// glue for the Go bindings
void GoAdd (const FunctionCallbackInfo &args) {
Isolate *isolate = args.GetIsolate();
bool valid = validateArgs(isolate, args);
if (!valid) {
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Wrong args")));
return;
}

GoFloat64 sum = Add(args[0]->NumberValue(), args[1]->NumberValue());
Local value = Number::New(isolate, sum);
args.GetReturnValue().Set(value);
}
```

Running these with `benchmark`, we get the following results:

```
js add x 91,215,401 ops/sec ±2.72% (85 runs sampled)
cpp add x 29,007,733 ops/sec ±3.02% (84 runs sampled)
go add x 376,173 ops/sec ±1.04% (89 runs sampled)
```

As expected, the Javascript implementation comes out on top with the most ops/sec.
The C++ implementation is quite slower, which is to be expected since there is some overhead
when switching contexts. The Go implementation ends up with a staggeringly low
376,173 ops/sec. Even with the small amount of code needed to invoke the Go function from
the C++ code, this is much slower than expected. It looks like there is a decent amount of
overhead when invoking Go functions from within C++, so Go might not be the best option
for relatively hot code.

Next, let's try some simple looping. In this test, we will increment and set a variable for every
iteration of the loop. We will also log the amount of time that it takes to go through the loop
to get an idea of how much time is spent in each function.

```js
function jsIncrement () {
let startDate = Date.now();
let v = 0

for (let i = 0; i < 2147483600; i++) {
v = i
}

console.log(`js: Time in ms to complete loop ${Date.now() - startDate} ms`);
return v
}
```

```c++
void CppIncrement (const FunctionCallbackInfo &args) {
Isolate *isolate = args.GetIsolate();
high_resolution_clock::time_point start = high_resolution_clock::now();

int value = 0;

for (int i = 0; i < 2147483600; i++) {
value = i;
}

high_resolution_clock::time_point end = high_resolution_clock::now();

auto diff = duration_cast(end - start);

cout << "cpp: Time in ms to complete loop " << diff.count() << "ms\n";

args.GetReturnValue().Set(Number::New(isolate, value));
}
```

```go
//export Increment
func Increment () int {
start := getTimestamp()
v := 0

for i := 0; i < 2147483600; i++ {
v = i
}

fmt.Printf("go: Time in ms to complete loop %v ms\n", getTimestamp() - start)
return v
}
```

Some more glue:

```c++
void GoIncrement (const FunctionCallbackInfo &args) {
Isolate *isolate = args.GetIsolate();

args.GetReturnValue().Set(Number::New(isolate, Increment()));
}
```

Now the results:

```
js: Time in ms to complete loop 4270 ms
js: Time in ms to complete loop 5339 ms
js: Time in ms to complete loop 4965 ms
js: Time in ms to complete loop 4931 ms
js: Time in ms to complete loop 4973 ms
js: Time in ms to complete loop 4933 ms
js: Time in ms to complete loop 4909 ms
js: Time in ms to complete loop 4913 ms
js: Time in ms to complete loop 4947 ms
js: Time in ms to complete loop 4940 ms
js increment x 0.20 ops/sec ±4.54% (5 runs sampled)
cpp: Time in ms to complete loop 934ms
cpp: Time in ms to complete loop 932ms
cpp: Time in ms to complete loop 938ms
cpp: Time in ms to complete loop 932ms
cpp: Time in ms to complete loop 939ms
cpp: Time in ms to complete loop 949ms
cpp: Time in ms to complete loop 918ms
cpp: Time in ms to complete loop 955ms
cpp: Time in ms to complete loop 922ms
cpp: Time in ms to complete loop 935ms
cpp: Time in ms to complete loop 939ms
cpp: Time in ms to complete loop 918ms
cpp: Time in ms to complete loop 928ms
cpp: Time in ms to complete loop 925ms
cpp increment x 1.07 ops/sec ±1.29% (7 runs sampled)
go: Time in ms to complete loop 970 ms
go: Time in ms to complete loop 968 ms
go: Time in ms to complete loop 970 ms
go: Time in ms to complete loop 953 ms
go: Time in ms to complete loop 982 ms
go: Time in ms to complete loop 957 ms
go: Time in ms to complete loop 959 ms
go: Time in ms to complete loop 968 ms
go: Time in ms to complete loop 971 ms
go: Time in ms to complete loop 956 ms
go: Time in ms to complete loop 948 ms
go: Time in ms to complete loop 965 ms
go: Time in ms to complete loop 973 ms
go: Time in ms to complete loop 986 ms
go increment x 1.04 ops/sec ±1.08% (7 runs sampled)
Fastest is cpp increment
```

In this test, there were very few function calls being made. As you can see, the Javascript
for loop was quite slow, taking about 5 seconds to complete. We know that heavy, blocking
computations like this shouldn't really be done with Javascript anyway, so this isn't a huge
surprise. The interesting thing is that both the Go and C++ functions are almost neck and neck in
terms of speed, with the C++ implementation having a slight lead over Go. Both are a little over 5
times faster than the JS variant. Maybe using Go for heavy processing can be somewhat feasible?
I think more exploring should be done before a solid conclusion can be made.

**Note:** Both of the above benchmarks test some extreme cases and are not really representative
of what you would see in the wild. Some more interesting tests (especially some involving goroutines)
would be helpful in determining whether Go can be a decent alternative to straight C++ for native modules.

Also, the machine I used for running these benchmarks is a 2016 Macbook Pro with a 2.7 GHz i7 and 16GB of RAM.

If you want to try running the benchmarks on your own, `cd` into the `benchmark` dir and run the `run-build.sh` script.
Then run `node benchmark.js` to start the tests.

### Todo:
- Add more benchmarks
- Introduce more realistic examples