Node.js Add-ons: Object transformation performance

While working on the redesigned Node.js C++ add-on for MQ, I had a question about transforming objects. What was the best method for Node.js performance? Noone answered the question on the internal channels I tried. I also couldn’t find anything definitive on external documentation or blogs. So I wrote my own tests …

The problem

The Node-API interface (often known as N-API) provides a way for Node.js applications to call “native” services. A C or C++ layer takes responsibility for marshalling parameters and calling the external API. In many cases, that external API is a C or C++ library. For my project, I needed to call IBM MQ functions that have a C API.

The Node application has objects containing the various parameters for the external API, and we need to convert them to the correct C formats.

As an example, imagine that the C API has a single parameter, a pointer to a structure:

struct s {
  int f1;
  char f2[32];
}

The JS application sets the values in a simple object analogue:

o = {f1:42, f2:"text"};

Something needs to take the fields from the JS object and set appropriate values in the C structure. What’s the best approach?

Options

There are two “obvious” ways to do this:

Conversion in the C++ layer

The C++ code is passed the JS object directly and extracts the field values invidually. Ignoring error handling, the C++ code would look something like:

using namespace Napi;
void Transformer(const CallbackInfo &info) {
  struct s s;
  Object o = info[0].As<Object>();

  s.f1 = o.Get("f1").As<Number>().Int32Value();
  strncpy(s.f2,o.Get("f2").As<String>().Utf8Value().c_str(),sizeof(s.f2));

  REALAPI(&s);
}

This seems to be the primary approach used in the add-on examples. Though that might be simply because it’s a good idea to show examples of using the API.

Conversion in the Node.JS layer

The JS layer builds a byte buffer that is passed through the C++ layer. The buffer matches exactly the layout of the structure. We may have to worry about string conversion though, and this example assumes little-endian integer layouts.

b = Buffer.alloc(36); // sizeof(C structure)
b.WriteInt32LE(o.f1,4);
for (i=0;i<o.f2.length;i++) {
  b[4+i] = o.f2.charCodeAt(i);
}
addon.REALAPI(b);

The corresponding C++ layer can then pass the buffer on without any real work:

void Transformer(const CallbackInfo &info) {
  Buffer<unsigned char> b = info[0].As<Buffer<unsigned char>>();
  REALAPI(b.Data());
}

Other approaches

For this trivial structure, a better answer might be to simply pass the 2 fields as separate parameters. But the real-world structures I’m dealing with have many more fields which make that impractical.

There are some external npm-provided packages that can do this conversion for you. You just describe the C structure using a JS syntax. But for various reasons – primarily because those do not work on all the platforms I’m interested in – those packages could not be used.

Performance Testing

It’s not immediately obvious which route performs better. In both situations the engine code has to lookup the field name in some kind of internal map for the object, and convert the element to a native format. I would not have been surprised to find them both being essentially the same.

But as I couldn’t find a definitive documented answer to the question, I ended up writing my own tests using the MQMD structure which has about 30 fields of different types (mostly integer and pseudo-string).

For the JS conversion option, I calculated the offsets of each field at initialisation time. The bufNew...functions update a local map with the offset for the field.

md = Buffer.alloc(MQC.MQMD_LENGTH_2);
o = {offset: 0};
mdOffsets = {};

u.bufNewWriteString(md, mdOffsets, "StrucId", "MD  ", 4, o);
u.bufNewWriteInt32(md, mdOffsets, "Version", MQC.MQMD_VERSION_2, o);
u.bufNewWriteInt32(md, mdOffsets, "Report", MQC.MQRO_NONE, o);
...

with the individual fields then converted when needed:

u.bufWriteInt32(mqmd, jsmd.MsgType, mdOffsets.MsgType);
u.bufWriteInt32(mqmd, jsmd.Expiry, mdOffsets.Expiry);

Similar code handled the reverse transform, reading from a returned Buffer to populate a new JS object. The private u.utility package for these tests handles big/little-endian conversions, and the string processing. And the mdOffsets map tells those functions where to set or read the elements so they don’t have to be listed in exactly the same order as in the C structure.

The C++ code just calls Object.Get(field) for each field, with assignments or strncpy as necessary. For the reverse path, it was something like Object.Set(field, Napi::Number::New(env, value)).

Results

The results were surprisingly clear. I ran the tests several times on a few different platforms. Conversions happened in both directions about 50K times. These were not necessarily the fastest systems, but the difference was significant enough, and consistent enough, that I was happy to accept the answer:

Elapsed [JS]  = 580ms
Elapsed [C++] = 1608ms
Ratio =  2.77

Building and passing a single byte buffer in the Node layer is a lot faster than building it in the addon C++ code.

Other considerations

The Node layer does not understand anything about C structure layouts and how they might be physically laid out – padding and alignment rules for example. For the MQ structures, that has always been a consideration in the original design. Some structures have explicit “reserved” fields, to make sure of word alignment where required. So I didn’t need to worry too much about that.

A second thing to think about is if the C structure contains pointer values. Often those might be pointers to other structures or elements from other API parameters. The best thing there is likely to be to leave the pointer values empty in the JS conversion, but then set them in the C++ layer. So the conversion is then a hybrid model:

void Transformer(const CallbackInfo &info) {
  struct s { int f1;char f2[32]; void *f3; } *ps;
  
  Buffer<unsigned char> b0 = info[0].As<Buffer<unsigned char>>();
  Buffer<unsigned char> b1 = info[1].As<Buffer<unsigned char>>();
  ps = b0.Data();
  ps->f3 = b1.Data();

  REALAPI(ps);
}

Both of the primary methods have a similar amount of code, with similar levels of complexity. At some point, one or other of the sides have to know about all of the fields by name and by type in the structure to do the copying. So performance is the main factor influencing the design.

Conclusion

It’s clear that doing transformation in the JS layer is the better-performing option. But I ended up using both models for different structures. Those that I expect to be used “rarely” get handled with the C++ transformer. Conveniently, these are also the more complicated structures, with pointers and elements that may have to be copied into temporary buffers to avoid problems that may occur with garbage collection kicking in and deleting the JS objects. I can better manage any malloc/free calls when it’s all in a single place.

The heavily-used structures – for MQ, those are the ones that are associated with every PUT or GET of a message – are instead being managed in the JS transformer.

I hope that anyone writing their own addon modules finds this useful.

This post was last updated on May 24th, 2023 at 12:30 pm

One thought on “Node.js Add-ons: Object transformation performance”

Leave a Reply

Your email address will not be published. Required fields are marked *