Skip to content
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

Improve from Binary/Json/Partial performance by roughly 30% #582

Merged

Conversation

jcready
Copy link
Contributor

@jcready jcready commented Sep 13, 2023

As mentioned in #306 (comment) there is a non-trivial performance hit for using Object.defineProperty(). Searching around you can find plenty of other mentions of this.

Originally my plan was to create a MessageInstance class property on the base MessageType class and then call const message = new this.MessageInstance() inside the create() method. Unfortunately jasmine seems consider two object to NOT be deeply equal to each other when one was created via new this.MessageInstance() vs. an object literal with the exact same structure. However, if instead we utilize Object.create() and pass in our prototype as the first argument (which should effectively be the same thing) then jasmine will consider the two objects deeply equal 🤷.

This change adds an optional messagePrototype property to the IMessageType interface (I'm hoping this isn't considered a backwards incompatible change). This messagePrototype is created inside the MessageType constructor and is stamped with the non-enumerable MESSAGE_TYPE symbol pointing back to this. Performing the stamping here means that we only are penalized for the Object.defineProperty() once per message "class" instead of once per message "instance".

I believe this change should be backwards compatible. Any old generated code should continue to work with the new runtime version, but newly generated code (only when optimized for speed) will not work with old runtime versions (optimized for size code will continue to work since it would go through reflectCreate() instead of generated code).

The below benchmarks were run with nodejs v20.6.1 (latest as of time of writing). According to these protobuf-ts (speed, bigint) will now read binary messages ~20% faster than protobufjs.

Before:

### read binary
google-protobuf                           :      12.485 ops/s
ts-proto                                  :      28.737 ops/s
protobuf-ts (speed)                       :      29.579 ops/s
protobuf-ts (speed, bigint)               :      30.049 ops/s
protobuf-ts (size)                        :      25.837 ops/s
protobuf-ts (size, bigint)                :      25.751 ops/s
protobufjs                                :      34.636 ops/s
### from partial
ts-proto                                  :      44.974 ops/s
protobuf-ts (speed)                       :      24.363 ops/s
protobuf-ts (size)                        :      22.906 ops/s
### read json object
ts-proto                                  :      44.283 ops/s
protobuf-ts (speed)                       :      18.129 ops/s
protobuf-ts (size)                        :      18.235 ops/s
protobufjs                                :      46.67  ops/s

After

### read binary
google-protobuf                           :      12.642 ops/s
ts-proto                                  :      28.755 ops/s
protobuf-ts (speed)            (+34.967%) :      39.922 ops/s
protobuf-ts (speed, bigint)    (+39.326%) :      41.866 ops/s
protobuf-ts (size)             (+29.551%) :      32.935 ops/s
protobuf-ts (size, bigint)     (+29.983%) :      33.472 ops/s
protobufjs                                :      34.89  ops/s
### from partial
ts-proto                                  :      44.282 ops/s
protobuf-ts (speed)            (+31.954%) :      32.148 ops/s
protobuf-ts (size)             (+19.226%) :      27.31  ops/s
### read json object
ts-proto                                  :      44.131 ops/s
protobuf-ts (speed)            (+27.690%) :      23.149 ops/s
protobuf-ts (size)             (+19.962%) :      21.875 ops/s
protobufjs                                :      48.058 ops/s

@jcready
Copy link
Contributor Author

jcready commented Dec 1, 2023

I would greatly appreciate a review of this PR. It's a fairly small change with a big speed improvement. Thank you for your time! 🙏

Copy link
Owner

@timostamm timostamm left a comment

Choose a reason for hiding this comment

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

Thanks, @jcready, this seems worth it.

Just left one comment.

Comment on lines +11 to +13
const msg: UnknownMessage = type.messagePrototype
? Object.create(type.messagePrototype)
: Object.defineProperty({}, MESSAGE_TYPE, {value: type});
Copy link
Owner

Choose a reason for hiding this comment

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

Can you add a comment here explaining that Object.create is used to avoid calling the expensive Object.defineProperty for every new instance?
We should also explain that the second code path is only there for BC, so we know that we can remove it with a major version release.

Copy link
Owner

@timostamm timostamm left a comment

Choose a reason for hiding this comment

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

LGTM!

@timostamm timostamm merged commit d1e441a into timostamm:main Dec 4, 2023
1 check passed
@timostamm
Copy link
Owner

Released in v2.9.2.

oliverlaz added a commit to GetStream/stream-video-js that referenced this pull request Dec 26, 2023
Includes the newly generated protobuf-ts models based on
GetStream/protocol#347
The newest version of `protobuf-ts` includes an important performance
optimization that we can benefit from in our SFU WS message decoding
flow.

More about it: timostamm/protobuf-ts#582
@Berd74
Copy link

Berd74 commented Mar 6, 2024

Hi,
Is there a way to generate the code in the old way? I mean to generate the objects (create/reflectionCreate function) using:
Object.defineProperty({}, MESSAGE_TYPE, {value: type});
?

Our code base relies on plain objects. Due to the changes, the outcoming objects from the generated code are no longer plain.

We would love to still keep up to date with the updates but right now we are forced to use the old version of the library (2.9.1).

@timostamm
Copy link
Owner

@jcready, we're using the prototype to avoid Object.defineProperty's perf penalty, while still keeping the property non-enumerable - correct?

@Berd74, I assume the object isn't considered to be plain because of its prototype? You could write a function that strips a message (and all messages it contains in properties) of its prototype. Can you give this a try?

@jcready
Copy link
Contributor Author

jcready commented Mar 7, 2024

@timostamm I believe the issue is similar to the one mentioned in #618 where some other library depends on (or will only operate on) objects which have all or some of the Object.prototype in their prototype chain. I don't believe the issue is related to having this other non-enumerable property on the prototype, just that things like constructor or toString() don't appear as "own properties" of the first prototype in the object's prototype chain or don't match Object.

As I mentioned in the PR summary my original plan for this was to simply create a MessageInstance class and then Object.defineProperty(MessageInstance.prototype, MESSAGE_TYPE, { value: this }) (where this is the MessageType instance) to create the non-enumerable pointer. This would've solved the issue since the MessageInstance class would've inherently extended the Object class and thus would've had all the normal prototype values. But this lead to these types of tests to fail

it('create() creates expected object', () => {
const msg = MyMessage.create({
stringField: "hello world",
});
const exp: MyMessage = {
boolField: false,
messageMap: {},
stringField: "hello world",
repeatedInt32Field: [],
result: {
oneofKind: undefined
},
}
expect(msg).toEqual(exp);
})

The issue was that Jasmine's .toEqual() takes the constructor of objects into account. See here:

      // Objects with different constructors are not equivalent, but `Object`s
      // or `Array`s from different frames are.
      const aCtor = a.constructor,
        bCtor = b.constructor;
      if (
        aCtor !== bCtor &&
        isFunction(aCtor) &&
        isFunction(bCtor) &&
        a instanceof aCtor &&
        b instanceof bCtor &&
        !(aCtor instanceof aCtor && bCtor instanceof bCtor)
      ) {
        diffBuilder.recordMismatch(
          constructorsAreDifferentFormatter.bind(null, this.pp)
        );
        return false;
      }

The biggest issue with switching to the class-based approach is that if that change already broke our tests then how likely is it that we still end up breaking someone else's tests that perhaps also used some form of toEqual() that had a different method of comparing the prototype chains? On the other hand my PR clearly already broke some user's actual runtime which depended on a certain prototype chain.


Alternatively we use the odd solution in #618 which basically pulls in all the stuff from the Object prototype into our messagePrototype without actually extending the prototype chain. I will say that if we went with this approach I would definitely want to make some changes like pulling the Object.getOwnPropertyDescriptors(Object.getPrototypeOf({})) into a constant defined outside the MessageType constructor. I do think this approach, while a little odd, may be the best solution to this issue.

The library mentioned in that PR was mobx which has checks like these in place:

if (!isPlainObject(target) && !isPlainObject(Object.getPrototypeOf(target))) {
    die(`'makeAutoObservable' can only be used for classes that don't have a superclass`)
}

Where isPlainObject is defined here:

const plainObjectString = Object.toString()

export function isObject(value: any): value is Object {
    return value !== null && typeof value === "object"
}

export function isPlainObject(value: any) {
    if (!isObject(value)) {
        return false
    }
    const proto = Object.getPrototypeOf(value)
    if (proto == null) {
        return true
    }
    const protoConstructor = Object.hasOwnProperty.call(proto, "constructor") && proto.constructor
    return (
        typeof protoConstructor === "function" && protoConstructor.toString() === plainObjectString
    )
}

@Berd74
Copy link

Berd74 commented Mar 8, 2024

@Berd74, I assume the object isn't considered to be plain because of its prototype? You could write a function that strips a message (and all messages it contains in properties) of its prototype. Can you give this a try?

This is our solution right now. After receiving objects from protobuf-ts (in our project: after using services like fetch requests, packets from webSocket, or packets from WebRTC data channels) we use a function that deeply checks if the object prototype has symbol MESSAGE_TYPE. If the object meets the condition we convert it back to the objects from version v2.9.1.

before transform:
image
after transform:
image

The solution is not perfect but it works. We hope in the future we will be able to get rid of this transformation.

@jcready I looked at the issue mentioned in #618 and I can confirm that we are having the same problem. We also use mobx.

@timostamm
Copy link
Owner

I don't believe the issue is related to having this other non-enumerable property on the prototype, just that things like constructor or toString() don't appear as "own properties" of the first prototype in the object's prototype chain or don't match Object.

You're right. Thanks for digging up the details.

I'm afraid that anything but a plain object (only own properties, no cyclic references, only JSON serializable types) will cause an issue in one framework or the other. At the same time, Protobuf messages ideally have the schema information attached to the object, so all necessary information for serialization and reflection is in one place. It can be a bit frustrating that it‘s extremely rare to see a framework provide hooks so that behavior can be adjusted to the domain models.

#618 isn't going to solve this issue completely, but it does look like an improvement.

@timostamm
Copy link
Owner

#618 was released in v2.9.4. @Berd74, this should work for you as well.

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