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

perf(core): fix cold start performance for big projects #10886

Closed
wants to merge 2 commits into from

Conversation

pocesar
Copy link

@pocesar pocesar commented Jan 15, 2023

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Other... Please describe:

What is the current behavior?

Very bad performance on cold starts, around 22 seconds for a medium sized project on Cloud Run

Issue Number: #10844

What is the new behavior?

Get the start time to around 140ms

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

I wasn't able to test locally

@micalevisk
Copy link
Member

can you run npm run build and npm t locally? I got few errors here

Comment on lines 38 to 39
if (typeof obj === 'function') {
return obj.name;
Copy link
Member

Choose a reason for hiding this comment

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

IIRC, and I could be misremembering here, this is how Nest v7 used to work, and it was changed in v8 so that we could have multiple classes with the same name without having a collision based on the name alone, which is why we use the full class reference.

Copy link
Author

@pocesar pocesar Jan 16, 2023

Choose a reason for hiding this comment

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

@jmcdo29 I see, thanks for the context. And it's interesting that it's working with a depth of 3 for me without any collisions, but I didn't test in other scenarios, which I apologize.

@micalevisk
Copy link
Member

I've applied this change in a private nestjs v9 app and it didn't work. It wasn't able to find the provider created by @nestjs/typeorm 😞

return value.name;
}
return hash(funcAsString, { ignoreUnknown: true });
public shallow(obj: Record<string, any>, depth = 3) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I like your intention of creating this function but I don't think that this function could be a good fit here.

We don't know exactly the depth that could be ideal to take as a parameter here.
If we log what is passed to this function, we could see values like this:

image

This is InternalCoreModule, which is passed in every initialization.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, we could remove this replacer function entirely, object-hash already deal with symbols here and with classes here

Copy link
Author

Choose a reason for hiding this comment

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

@H4ad thanks for going through it, indeed I was fitting the value to what works with the project at hand, which uses nestjs 8.x.
IMHO the regex there is an unnecessary overhead for something like this.

In theory the recursion is infinite at current state, even with Circular traps. It could be make better with tail call recursion so it won't rely on the stack though.

If this lands in object-hash, this function could definitely go away

Copy link
Contributor

Choose a reason for hiding this comment

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

object-hash already handles Circular references, so we can just pass the entire object to it without worrying.

@H4ad
Copy link
Contributor

H4ad commented Jan 17, 2023

I did some testing, if we remove fast-json-stringify and leave only object-hash, here's what we see in the profiler info when we call #create 10,000 times:

Code:

const opaqueToken = {
    id: moduleId,
    module: this.getModuleName(metatype),
    dynamic: dynamicModuleMetadata,
};

Before:

Statistical profiling result from perf-startup-10000.log, (48119 ticks, 101 unaccounted, 0 excluded).

 [Shared libraries]:
   ticks  total  nonlib   name
  38851   80.7%          /home/h4ad/.asdf/installs/nodejs/16.16.0/bin/node
    649    1.3%          /usr/lib/x86_64-linux-gnu/libc-2.31.so
      5    0.0%          /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.28
      4    0.0%          [vdso]

After:

Statistical profiling result from perf-startup-10000_without_fast_stringify.log, (624 ticks, 0 unaccounted, 0 excluded).

 [Shared libraries]:
   ticks  total  nonlib   name
    527   84.5%          /home/h4ad/.asdf/installs/nodejs/16.16.0/bin/node
     16    2.6%          /usr/lib/x86_64-linux-gnu/libc-2.31.so

I tried a couple of times just to check if I was not doing something wrong but is insane the difference.

But I had a problem, if we just do this optimization we see this error:

[Nest] 161973  - 01/16/2023, 11:40:51 PM   ERROR [ExceptionHandler] Method Map.prototype.entries called on incompatible receiver #<ModulesContainer>
TypeError: Method Map.prototype.entries called on incompatible receiver #<ModulesContainer>
    at Map.entries (<anonymous>)
    at Function.from (<anonymous>)
    at Object._map (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:387:23)
    at Object._object (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:219:30)
    at Object.dispatch (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:190:30)
    at /home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:251:18
    at Array.forEach (<anonymous>)
    at Object._object (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:247:21)
    at Object._function (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:324:14)
    at Object.dispatch (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:190:30)

This issue is caused by ModulesContainer.

export class ModulesContainer extends Map<string, Module> {
private readonly _applicationId = uuid();
get applicationId(): string {
return this._applicationId;
}
}

I think that by extending Map, we cannot call the .entries() method.
I've tried to fix this by not extending Map and implementing all the methods, but I see an error elsewhere.

[Nest] 168165  - 01/16/2023, 8:55:49 PM   ERROR [ExceptionHandler] Cannot read property 'statusCode' of undefined
TypeError: Cannot read property 'statusCode' of undefined
    at IncomingMessage.fresh (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/@nestjs/platform-express/node_modules/express/lib/request.js:470:20)
    at /home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:251:33
    at Array.forEach (<anonymous>)
    at Object._object (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:247:21)
    at Object.dispatch (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:190:30)
    at /home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:251:18
    at Array.forEach (<anonymous>)
    at Object._object (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:247:21)
    at Object.dispatch (/home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:190:30)
    at /home/h4ad/Projects/liga.assethub/liga.api.nestjs.asset-hub/node_modules/object-hash/index.js:251:18

So I thought of two options:

First, ignore InternalCoreModule entirely in ModuleTokenFactory by just adding if (metatype instanceof InternalCoreModule) return randomStringGenerator().
This solution also helps a bit on startup because this module has a lot of internal properties and as far as I can see this module doesn't contain anything relevant that can be changed by the user, the only difference I see when I patch this solution in two private projects was:

image

This difference was caused because in the second API I pass new ExpressAdapter() as second argument of #create.

The second option is to pass the excludeKeys option in the objectHash and start renaming all the internal properties that might throw some error to not be hashed by the object-hash, I haven't tried to create a POC but I think that could work.

I think only Kamil could answer if worth to continue this optimization based on what I discovered, the option with lower effort is to ignore InternalCoreModule and with the higher effort is to ignore with excludeKeys, but I don't know exactly the implications of ignoring this module.

I tried importing InternalCoreModule inside my private API and it didn't throw any error, so I think we could assume that module could be ignored.

@kamilmysliwiec
Copy link
Member

First, ignore InternalCoreModule entirely in ModuleTokenFactory by just adding if (metatype instanceof InternalCoreModule) return randomStringGenerator().

This sounds like a nice workaround

@H4ad
Copy link
Contributor

H4ad commented Jan 17, 2023

@pocesar Hey man, could you push the changes based on my comment?

Basically, you will need to remove this.shallow to leave the entire object to be hashed, and also put an if to guard against InternalCoreModule. This is enough to speed up the initialization.

Also, after pushing the changes, we will need to test better the modules to check if we could find modules that could not be hashed to document to users.

@pocesar
Copy link
Author

pocesar commented Jan 18, 2023

@H4ad if you want to go ahead and make a PR feel free! we needed this faster so this is my monkeypatch for it if anyone needs it:

working fine and down to 48ms locally

/* eslint-disable no-param-reassign */
import type { Type } from '@nestjs/common/interfaces/type.interface.js';
import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util.js';
import type { INestApplication, DynamicModule } from '@nestjs/common';
import type { NestContainer } from '@nestjs/core';
import hash from 'object-hash';
import { NestFactoryStatic } from '@nestjs/core/nest-factory.js';
import { ModuleCompiler } from '@nestjs/core/injector/compiler.js';

export class FastModuleTokenFactory {
  private readonly moduleIdsCache = new WeakMap<Type<unknown>, string>();

  public create(
    metatype: Type<unknown>,
    dynamicModuleMetadata?: Partial<DynamicModule> | undefined,
  ): string {
    const moduleId = this.getModuleId(metatype);

    const opaqueToken = {
      id: moduleId,
      module: this.getModuleName(metatype),
      dynamic: dynamicModuleMetadata,
    };

    return hash(this.replace(opaqueToken));
  }

  public getModuleId(metatype: Type<unknown>): string {
    let moduleId = this.moduleIdsCache.get(metatype);
    if (moduleId) {
      return moduleId;
    }
    moduleId = randomStringGenerator() as string;
    this.moduleIdsCache.set(metatype, moduleId);
    return moduleId;
  }

  public getModuleName(metatype: Type<any>): string {
    return metatype.name;
  }

  replace(obj: Record<string, any>, maxDepth = 4) {
    if (typeof obj === 'object' && obj !== null) {
      if (maxDepth <= 0) {
        return `[object Object(${maxDepth})]`;
      }

      // for-loop + Object.create is much faster than reduce
      const result = Object.create(null);

      for (const key of Object.keys(obj)) {
        result[key] = this.replace(obj[key], maxDepth - 1);
      }

      return result;
    }

    if (typeof obj === 'function' || typeof obj === 'symbol') {
      return `${(obj as any).name ?? (obj as any).toString()}${maxDepth}`;
    }

    return obj;
  }
}

export default async <T extends INestApplication>(
  rootModule: any,
  adapter: any,
): Promise<T> => {
  const moduleTokenFactory = new FastModuleTokenFactory();

  const instance = new Proxy(new NestFactoryStatic(), {
    get(target, p) {
      if (p === 'initialize') {
        return function trapInitialize(
          module: T,
          container: NestContainer,
          ...args
        ) {
          (container as any).moduleTokenFactory = moduleTokenFactory;
          (container as any).getModuleTokenFactory = () => moduleTokenFactory;
          (container as any).moduleCompiler = new ModuleCompiler(
            moduleTokenFactory as any,
          );
          return target[p].call(target, module, container, ...args);
        };
      }

      return target[p];
    },
  });

  return instance.create<T>(rootModule, adapter);
};

@pocesar pocesar closed this Jan 18, 2023
@pocesar pocesar deleted the fix/performance branch January 18, 2023 21:42
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.

None yet

5 participants