Skip to content

[gql_code_builder] optimize slow code generation to reduce build time as 10% of previous. #413

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

Conversation

dehypnosis
Copy link
Contributor

@dehypnosis dehypnosis commented Aug 9, 2023

Background

Hi Team and especially @knaeckeKami,

There is an old issue in this library. Which is gql-dart/ferry#143, first reported at Jan, 2021.
I made this PR to address this issue again and share the working draft of a possible solution.

There can be many aspects making build slower, but here I focused on how gql_code_builder generates data models.
A code generation for GraphQL type class and built value class gets extremely slower as schema and query gets larger.

The main reasons for that I found:

  • With recursively nesting fragments, the number of models generated grows exponentially.
  • Even for a single FragmentSpreadNode selection that is identical to a fragment model already created, a builder will generate the same code just with a different name.

Example

query TestQuery {
    currentUser {
        ...UserFragment
    }

    currentUser2: currentUser {
        ...UserFragment
        location {
            lat
            lng
        }
        moderation {
            ratingPlaceholder
        }
    }
}

fragment UserFragment on User {
   # ...OMITTED
   location {
      ...LocationFragment
   }
   moderation {
      ...ModerationFragment
   }
}

# ...OMITTED

Assume we have above query and fragments (though it seems strange).

  • Here we can replace GUserFragmentData_location to GLocationFragmentData. Also for moderation field too.
  • And, we know that we can reuse GUserFragmentData for GTestQueryData_currentUser.
  • Furthermore, if UserFragment already includes both location {lat, lng} and moderation {ratingPlaceholder}, here GUserFragmentData can be used for GTestQueryData_currentUser2 too.

Because of this graph-way nature of GraphQL, we can reuse a lot of our model code. However, the current code generator does not reflect this reuse logic, and so recursively nested Fragments are generating a huge amount of similar code.

Changes

I created this draft solution that performs the optimizations for reusable fragment models mentioned in the example above, including InlineFragmentNode and WhenExtension support too. So it covers all models generated from current builder.

I said it is a draft because if a builder with this patch generates code against an existing schema, it will not generate many of the models that were previously generated, and it will also change the type signatures of the reusable getters in many of the former models. This could be a breaking change for users, so for now, I look forward to discussing this approach further.

And there has been made no regression tests for this patch yet.
But I am using this patched builder with my own product and found no issues for now.

See, there were breaking changes as mentioned above, so I had to fix few type signatures and model names.

FYI: Below are full errors after using this patched build.
Be note that this app is on production with enough number of features.

lib/app/components/user.dart:199:48: Error: Type
'GUserFragment_viewerMatch' not found.
extension GUserViewerMatchFragmentExtension on GUserFragment_viewerMatch {
                                               ^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/profile.female.dart:84:14: Error: Type
'GViewerSearchQueryData_searchUsers_matched' not found.
  final List<GViewerSearchQueryData_searchUsers_matched> users;
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/components/moderation.dart:16:25: Error: Method not found:
'GUserFragmentData_moderation'.
  static final _guest = GUserFragmentData_moderation((b) {
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/account.blocked_contacts.dart:4:9: Error:
'GViewerUserBlockedContactsQueryData_currentUserBlockedContacts_items'
isn't a type.
        GViewerUserBlockedContactsQueryData_currentUserBlockedContacts_item
        s>(
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        ^
lib/app/pages/sign_up.profile.dart:29:26: Error: The method
'GUserFragmentData_locationBuilder' isn't defined for the class
'SignUpProfilePage'.
 - 'SignUpProfilePage' is from 'package:app/app/pages/sign_up.dart'
 ('lib/app/pages/sign_up.dart').
Try correcting the name to the name of an existing method, or defining a
method named 'GUserFragmentData_locationBuilder'.
            b.location = GUserFragmentData_locationBuilder()
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/sign_up.profile.dart:37:28: Error: The method
'GUserFragmentData_moderationBuilder' isn't defined for the class
'SignUpProfilePage'.
 - 'SignUpProfilePage' is from 'package:app/app/pages/sign_up.dart'
 ('lib/app/pages/sign_up.dart').
Try correcting the name to the name of an existing method, or defining a
method named 'GUserFragmentData_moderationBuilder'.
            b.moderation = GUserFragmentData_moderationBuilder()
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/sign_up.profile.dart:43:25: Error: The method
'GUserFragmentData_profileBuilder' isn't defined for the class
'SignUpProfilePage'.
 - 'SignUpProfilePage' is from 'package:app/app/pages/sign_up.dart'
 ('lib/app/pages/sign_up.dart').
Try correcting the name to the name of an existing method, or defining a
method named 'GUserFragmentData_profileBuilder'.
            b.profile = GUserFragmentData_profileBuilder()
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/sign_up.profile.dart:51:29: Error: The method
'GUserFragmentData_viewerMatchBuilder' isn't defined for the class
'SignUpProfilePage'.
 - 'SignUpProfilePage' is from 'package:app/app/pages/sign_up.dart'
 ('lib/app/pages/sign_up.dart').
Try correcting the name to the name of an existing method, or defining a
method named 'GUserFragmentData_viewerMatchBuilder'.
            b.viewerMatch = GUserFragmentData_viewerMatchBuilder()
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/profile.female.dart:84:14: Error:
'GViewerSearchQueryData_searchUsers_matched' isn't a type.
  final List<GViewerSearchQueryData_searchUsers_matched> users;
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/community.view.dart:250:22: Error: The getter
'GCommunityReplyDetailFragmentData_replies_nodes' isn't defined for the
class '_Body'.
 - '_Body' is from 'package:app/app/pages/community.view.dart'
 ('lib/app/pages/community.view.dart').
Try correcting the name to the name of an existing getter, or defining a
getter or field named 'GCommunityReplyDetailFragmentData_replies_nodes'.
              return
              GCommunityReplyDetailFragmentData_replies_nodes.fromJson(
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/community.feed.edit.dart:80:20: Error:
'GCommunityFeedQueryData_node__asCommunityFeed' isn't a type.
                as GCommunityFeedQueryData_node__asCommunityFeed?) ??
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/app/pages/account.notification.dart:126:9: Error: The getter
'GUpdateNotificationSubscriptionData_updateNotificationSubscription' isn't
defined for the class 'AccountNotificationSettingsPage'.
 - 'AccountNotificationSettingsPage' is from
 'package:app/app/pages/account.notification.dart'
 ('lib/app/pages/account.notification.dart').
Try correcting the name to the name of an existing getter, or defining a
getter or field named
'GUpdateNotificationSubscriptionData_updateNotificationSubscription'.
        GUpdateNotificationSubscriptionData_updateNotificationSubscription
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Comparison

Here's a quick summary of the results with my own product:

  • Reduced to about 10% of the previous in build time.
  • Reduced to about 11% of the previous in amount of generated "data" codes.

Compared only with *.data.g.dart and *.data.dart files, which this patch is effectively applied to.

I think the result will fairly vary on the structure of scheme and query complexity.
For me, I have 7-depth nested fragment at most. And used fragments pretty much in anywhere.

*My product has about 2k lines of `*.graphql` codes.
scheme $ wc -l *
      51 query.admin.graphql
     258 query.community.graphql
     565 query.graphql
      67 scalar.dart
      11 schema.dart
    1095 schema.graphql
    2047 total

BEFORE

It took about 20 minutes for a build, and generated about 552k lines of "data" dart codes . The total number of characters in the generated codes is 22M.
generated $ find . -type f -name "*data*" | xargs wc
   11845   26827  433099 ./query.admin.data.gql.g.dart
  348729  733625 14425654 ./query.community.data.gql.g.dart
  112482  243263 4116248 ./query.data.gql.g.dart
    1810    3517   58030 ./query.admin.data.gql.dart
   18976   35173  657436 ./query.data.gql.dart
   58155   99920 2418975 ./query.community.data.gql.dart
  551997 1142325 22109442 total

AFTER

A builder with this patch, It took about 2 minutes for a build, and generated about 68k lines of "data" dart codes . The total number of characters in the generated codes is 2.4M.
generated $ find . -type f -name "*data*" | xargs wc -l
    6154   13718  220560 ./query.admin.data.gql.g.dart
   21882   47403  804926 ./query.community.data.gql.g.dart
   30665   69059 1085963 ./query.data.gql.g.dart
     981    1939   32695 ./query.admin.data.gql.dart
    4776    9733  156508 ./query.data.gql.dart
    3555    6833  123426 ./query.community.data.gql.dart
   68013  148685 2424078 total

Diff

Also please refer below broken tests as an example of effective changes.

Note

Thank you for reviewing it.
And I am looking forward to any thoughts about this approach.

@knaeckeKami
Copy link
Collaborator

Thanks for your PR. I will take a deeper look on the weekend.

IMO a breaking change on this is acceptable for this improvement. If possible without much overhead, I would like to make this configurable though, so users have some time to migrate.

@@ -19,6 +20,9 @@ Library buildDataLibrary(
generateMaybeWhenExtensionMethod: false,
),
]) {
final fragmentMap = _fragmentMap(docSource);
final dataClassAliasMap = _dataClassAliasMap(docSource, fragmentMap);
Copy link
Contributor Author

@dehypnosis dehypnosis Aug 9, 2023

Choose a reason for hiding this comment

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

Okay, to make this configurable, we can simply update this line. So a new gql_build option need to be added for backward compatibility.
e.g

final dataClassAliasMap = featureEnable ? _dataClassAliasMap(docSource, fragmentMap) : <String, Reference>{};

@knaeckeKami knaeckeKami self-assigned this Aug 12, 2023
@knaeckeKami
Copy link
Collaborator

Looks good to me.

Thanks so much for this contribution.

Also tried it on the codebase I'm working on and seems to work fine (although I don't see much improvement in build times as I don't have many nested, shared fragment spreads ).

In order to ship this, please add a configuration option to the buildDataLibrary function which defaults to the current behaviour.

I suggest adding a simple config class, like for the when() methods for the inline fragment spreads, in order to have the option to add more parameters later without changing the signature.

The default value when the config variable is not passed should be the current behaviour that emits new classes for every instance of the fragment in order to not break existing users.

From that, I can take the steps to ships this, i.e.:

  • add the configuration option to gql_build and ferry_generator
  • add end to end codegen tests
  • publish new versions of the affected libraries

@dehypnosis dehypnosis force-pushed the feat/gql-code-builder-optimization branch from c7fc27f to 4f55b56 Compare August 13, 2023 15:40
@dehypnosis
Copy link
Contributor Author

dehypnosis commented Aug 13, 2023

Hi @knaeckeKami

I found a hidden bug and fixed that. Also I added configuration signature as your explanation.
Please check last two commits.
Thanks.

P.S. Quick Question: Is there anyway to deliver options to gql_build with build.yaml from my app which is using ferry? For that, should It be integrated to ferry_generator?

@knaeckeKami
Copy link
Collaborator

knaeckeKami commented Aug 13, 2023

ferry does not use gql_build anymore, as this lead to configuration overhead and user confusion.

But we should still maintain gql_build as other projects depend on it.

so, this change also has to be integrated into ferry_generator as well.

@knaeckeKami knaeckeKami merged commit 276032e into gql-dart:master Aug 13, 2023
knaeckeKami added a commit to gql-dart/ferry that referenced this pull request Aug 13, 2023
…n order to re-use selection sets with only a single inline fragment spread. see gql-dart/gql#413 for details.
knaeckeKami added a commit to gql-dart/ferry that referenced this pull request Aug 13, 2023
… with a single inline fragment spread as selection (#530)

* feat(ferry_generator): add data_class_config: reuse_fragment option in order to re-use selection sets with only a single inline fragment spread. see gql-dart/gql#413 for details.

In order to opt in to this (breaking) change, add

```yaml
          data_class_config:
            reuse_fragments: true
```
To your in the config for your graphql_builder of your build.yaml
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.

2 participants