Skip to content

Refactor visibility_detector to avoid forcing compositing. #367

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
merged 19 commits into from
May 24, 2022

Conversation

dnfield
Copy link
Contributor

@dnfield dnfield commented May 10, 2022

Instead, use methods on RenderObject and new framework API to
listen to compositing and determine the transform/clip.

Removes an O(N^2) algorithm from the ancestor walk.

Deletes the custom layer that is no longer used.

Minor update to main.dart to be more desktop friendly.

Tests adjusted to use new API. Removed API has no usages
in internal codesearch.

Depends on flutter/flutter#103378

Instead, use methods on RenderObject and new framework API to
listen to compositing and determine the transform/clip.

Removes an O(N^2) algorithm from the ancestor walk.

Deletes the custom layer that is no longer used.

Minor update to main.dart to be more desktop friendly.

Tests adjusted to use new API. Removed API has no usages
in internal codesearch.
@@ -74,6 +74,7 @@ class VisibilityDetectorDemo extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: title,
scrollBehavior: const MaterialScrollBehavior().copyWith(scrollbars: false),
Copy link
Member

Choose a reason for hiding this comment

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

is this related?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This makes the framework stop complaining when you run the example app in desktop mode. It's not essential for this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(No, it's not related)

@dnfield
Copy link
Contributor Author

dnfield commented May 11, 2022

Ok. In some local benchmarking on a Pixel 4 this is showing some improvements over the old version now. I've made the following changes from initial:

  • Defer actually calculating visibility (original version of this patch was deferring updates about visibility but eagerly calculating)
  • Make sure to unregister callbacks when paint is called and add tests (original patch was leaving old callbacks around which made compositing slow down).
  • Cache ancestor lookups. Testing in the demo app and customer: money's simplified app show a good number of hits on this cache.

The caching logic can probably be further improved. The lookups would benefit from using the layer tree, but that's currently blocked on flutter/flutter#103469

@dnfield
Copy link
Contributor Author

dnfield commented May 11, 2022

I'll mark this as ready for review once the framework PR has landed and I can put a semi-meaningful framework version into the SDK constraint.

@jamesderlin jamesderlin self-requested a review May 11, 2022 06:59
@dnfield
Copy link
Contributor Author

dnfield commented May 11, 2022

Moto g4 at head:

  "average_frame_build_time_millis": 2.9323076923076914,
  "90th_percentile_frame_build_time_millis": 4.431,
  "99th_percentile_frame_build_time_millis": 5.282,
  "worst_frame_build_time_millis": 6.973,
  "missed_frame_build_budget_count": 0,
  "average_frame_rasterizer_time_millis": 10.860470588235296,
  "90th_percentile_frame_rasterizer_time_millis": 15.983,
  "99th_percentile_frame_rasterizer_time_millis": 45.917,
  "worst_frame_rasterizer_time_millis": 45.917,
  "missed_frame_rasterizer_budget_count": 5,
  "frame_count": 52,
  "frame_rasterizer_count": 51,
  "new_gen_gc_count": 2,
  "old_gen_gc_count": 0,
  "average_vsync_transitions_missed": 1.4166666666666667,
  "90th_percentile_vsync_transitions_missed": 2.0,
  "99th_percentile_vsync_transitions_missed": 3.0,
  "average_vsync_frame_lag": 563.8333333333334,
  "90th_percentile_vsync_frame_lag": 1266.0,
  "99th_percentile_vsync_frame_lag": 1659.0,
  "average_layer_cache_count": 1.6538461538461537,
  "90th_percentile_layer_cache_count": 3.0,
  "99th_percentile_layer_cache_count": 4.0,
  "worst_layer_cache_count": 4.0,
  "average_layer_cache_memory": 0.15911871153846155,
  "90th_percentile_layer_cache_memory": 0.441193,
  "99th_percentile_layer_cache_memory": 0.512878,
  "worst_layer_cache_memory": 0.512878,
  "average_picture_cache_count": 10.903846153846153,
  "90th_percentile_picture_cache_count": 15.0,
  "99th_percentile_picture_cache_count": 16.0,
  "worst_picture_cache_count": 16.0,
  "average_picture_cache_memory": 7.179940769230776,
  "90th_percentile_picture_cache_memory": 9.070084,
  "99th_percentile_picture_cache_memory": 9.140945,
  "worst_picture_cache_memory": 9.140945,
  "total_ui_gc_time": 4.757,

Moto G4 with this patch:

  "average_frame_build_time_millis": 3.1556440677966098,
  "90th_percentile_frame_build_time_millis": 4.479,
  "99th_percentile_frame_build_time_millis": 5.321,
  "worst_frame_build_time_millis": 5.573,
  "missed_frame_build_budget_count": 0,
  "average_frame_rasterizer_time_millis": 9.560338983050848,
  "90th_percentile_frame_rasterizer_time_millis": 13.052,
  "99th_percentile_frame_rasterizer_time_millis": 34.242,
  "worst_frame_rasterizer_time_millis": 38.225,
  "missed_frame_rasterizer_budget_count": 5,
  "frame_count": 59,
  "frame_rasterizer_count": 59,
  "new_gen_gc_count": 2,
  "old_gen_gc_count": 0,
"average_vsync_transitions_missed": 1.3076923076923077,
  "90th_percentile_vsync_transitions_missed": 2.0,
  "99th_percentile_vsync_transitions_missed": 2.0,
  "average_vsync_frame_lag": 554.8,
  "90th_percentile_vsync_frame_lag": 1339.0,
  "99th_percentile_vsync_frame_lag": 1833.0,
  "average_layer_cache_count": 1.6,
  "90th_percentile_layer_cache_count": 3.0,
  "99th_percentile_layer_cache_count": 4.0,
  "worst_layer_cache_count": 4.0,
  "average_layer_cache_memory": 0.1566712166666669,
  "90th_percentile_layer_cache_memory": 0.441193,
  "99th_percentile_layer_cache_memory": 0.512878,
  "worst_layer_cache_memory": 0.512878,
  "average_picture_cache_count": 9.6,
  "90th_percentile_picture_cache_count": 14.0,
  "99th_percentile_picture_cache_count": 15.0,
  "worst_picture_cache_count": 15.0,
  "average_picture_cache_memory": 5.347194483333333,
  "90th_percentile_picture_cache_memory": 6.723358,
  "99th_percentile_picture_cache_memory": 7.494987,
  "worst_picture_cache_memory": 7.494987,
  "total_ui_gc_time": 4.762,

@dnfield
Copy link
Contributor Author

dnfield commented May 11, 2022

Wembley 1gb:

Base (2nd run, try to avoid some shader comp):

  "average_frame_build_time_millis": 3.4019772727272724,
  "90th_percentile_frame_build_time_millis": 6.062,
  "99th_percentile_frame_build_time_millis": 15.354,
  "worst_frame_build_time_millis": 15.354,
  "missed_frame_build_budget_count": 0,
  "average_frame_rasterizer_time_millis": 13.532755555555557,
  "90th_percentile_frame_rasterizer_time_millis": 20.314,
  "99th_percentile_frame_rasterizer_time_millis": 47.82,
  "worst_frame_rasterizer_time_millis": 47.82,
  "missed_frame_rasterizer_budget_count": 8,
  "frame_count": 44,
  "frame_rasterizer_count": 45,
  "new_gen_gc_count": 0,
  "old_gen_gc_count": 0,

  "average_vsync_transitions_missed": 1.2272727272727273,
  "90th_percentile_vsync_transitions_missed": 2.0,
  "99th_percentile_vsync_transitions_missed": 2.0,
  "average_vsync_frame_lag": 146.12765957446808,
  "90th_percentile_vsync_frame_lag": 180.0,
  "99th_percentile_vsync_frame_lag": 342.0,
  "average_layer_cache_count": 1.5434782608695652,
  "90th_percentile_layer_cache_count": 3.0,
  "99th_percentile_layer_cache_count": 4.0,
  "worst_layer_cache_count": 4.0,
  "average_layer_cache_memory": 0.08224230434782609,
  "90th_percentile_layer_cache_memory": 0.196167,
  "99th_percentile_layer_cache_memory": 0.228027,
  "worst_layer_cache_memory": 0.228027,
  "average_picture_cache_count": 14.804347826086957,
  "90th_percentile_picture_cache_count": 15.0,
  "99th_percentile_picture_cache_count": 16.0,
  "worst_picture_cache_count": 16.0,
  "average_picture_cache_memory": 2.71343154347826,
  "90th_percentile_picture_cache_memory": 3.981194,
  "99th_percentile_picture_cache_memory": 4.013054,
  "worst_picture_cache_memory": 4.013054,

Patch:

  "average_frame_build_time_millis": 2.9978400000000005,
  "90th_percentile_frame_build_time_millis": 4.965,
  "99th_percentile_frame_build_time_millis": 12.949,
  "worst_frame_build_time_millis": 12.949,
  "missed_frame_build_budget_count": 0,
  "average_frame_rasterizer_time_millis": 12.143411764705878,
  "90th_percentile_frame_rasterizer_time_millis": 17.092,
  "99th_percentile_frame_rasterizer_time_millis": 43.605,
  "worst_frame_rasterizer_time_millis": 43.605,
  "missed_frame_rasterizer_budget_count": 9,
  "frame_count": 50,
  "frame_rasterizer_count": 51,
  "new_gen_gc_count": 2,
  "old_gen_gc_count": 0,
  "average_vsync_transitions_missed": 1.2941176470588236,
  "90th_percentile_vsync_transitions_missed": 2.0,
  "99th_percentile_vsync_transitions_missed": 2.0,
  "average_vsync_frame_lag": 158.23076923076923,
  "90th_percentile_vsync_frame_lag": 210.0,
  "99th_percentile_vsync_frame_lag": 296.0,
  "average_layer_cache_count": 1.4615384615384615,
  "90th_percentile_layer_cache_count": 3.0,
  "99th_percentile_layer_cache_count": 4.0,
  "worst_layer_cache_count": 4.0,
  "average_layer_cache_memory": 0.07676353846153845,
  "90th_percentile_layer_cache_memory": 0.196167,
  "99th_percentile_layer_cache_memory": 0.228027,
  "worst_layer_cache_memory": 0.228027,
  "average_picture_cache_count": 13.596153846153847,
  "90th_percentile_picture_cache_count": 15.0,
  "99th_percentile_picture_cache_count": 15.0,
  "worst_picture_cache_count": 16.0,
  "average_picture_cache_memory": 3.36692173076923,
  "90th_percentile_picture_cache_memory": 8.623688,
  "99th_percentile_picture_cache_memory": 8.623688,
  "worst_picture_cache_memory": 8.623688,
  "total_ui_gc_time": 3.323,

@jonahwilliams
Copy link
Contributor

those are great numbers

@dnfield
Copy link
Contributor Author

dnfield commented May 11, 2022

Looking into some internal test failures.

@dnfield
Copy link
Contributor Author

dnfield commented May 14, 2022

flutter/flutter#103768 and flutter/flutter#103748 are blocking this right now

@dnfield
Copy link
Contributor Author

dnfield commented May 14, 2022

Latest change is down to ~20 internal failures (not including minor scuba changes) as long as the linked flutter/flutter patches are patched in. Will keep looking into that.

@dnfield
Copy link
Contributor Author

dnfield commented May 16, 2022

flutter/flutter#103931

@dnfield
Copy link
Contributor Author

dnfield commented May 21, 2022

I'm down to one more issue on this, where it seemslike sometimes I'm losing track of a scroll offset or transform in the tree.

@dnfield dnfield marked this pull request as ready for review May 24, 2022 00:16
@dnfield
Copy link
Contributor Author

dnfield commented May 24, 2022

This results in a number of small golden changes in g3, mainly because of removing layers/pixel snapping.

There are a couple cases where images change: one is a semantics debugger where the colors are changing, and another looks like the widget acutally loads more - but all the visibility detection related tests are passing.

@dnfield
Copy link
Contributor Author

dnfield commented May 24, 2022

I don' thave permission to add reviewers in this repo or to land. I'd appreciate any feedback from @jonahwilliams and @goderbauer on this.

@dnfield
Copy link
Contributor Author

dnfield commented May 24, 2022

The internal CL and current golden changes are at cl/450011940 - you can see the associated tap run there if you are a Googler.

Copy link
Member

@goderbauer goderbauer left a comment

Choose a reason for hiding this comment

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

Overall, this seems like a good approach.

VisibilityDetectorLayer.forget(key);
super.paint(context, offset);
return;
if (onVisibilityChanged != null) {
Copy link
Member

Choose a reason for hiding this comment

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

Looks like paint here and in RenderSliverVisibilityDetector have a lot in common, the inly difference is the second argument passed to _scheduleUpdate? Maybe move paint to the base and have this class and RenderSliverVisibilityDetector implement just a method that returns the value for that second argument?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done, as mentioned elsehwere moved this to a Rect get bounds; on the mixin, which simplified things a bit.

///
/// This is used for testing, and always returns 0 outside of debug mode.
@visibleForTesting
int? get debugScheduleUpdateCount {
Copy link
Member

Choose a reason for hiding this comment

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

type as non-nullable int? (or make it return null in non-debug mode?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

assert(!debugDisposed!);
final ContainerLayer? container =
layer is ContainerLayer ? layer : layer.parent;
_scheduleUpdate(container, semanticBounds);
Copy link
Member

Choose a reason for hiding this comment

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

Why semanticBounds over paintBounds or Offset.zero & size?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's what the original code used.

In the framework, semanticBounds and paintBounds are always the same, and for box classes they're Offset.zero & size :)

}
} else if (_timer == null) {
// We use a normal [Timer] instead of a [RestartableTimer] so that changes
// to the update duration will be picked up automatically.
Copy link
Member

Choose a reason for hiding this comment

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

update duration = update interval?

How does this timer "automatically" pick up changes to that value?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is unchanged from the original.

I think the idea here is that if it were to use a RestartableTimer it'd be stuck with the originally supplied duration, as opposed to creating a new timer each time with the right duration.

import 'package:flutter/foundation.dart';

import 'visibility_detector_layer.dart';
import 'render_visibility_detector.dart';

/// A [VisibilityDetectorController] is a singleton object that can perform
/// actions and change configuration for all [VisibilityDetector] widgets.
Copy link
Member

Choose a reason for hiding this comment

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

(unrelated): The existence of this class is odd. A singleton instance that just calls static methods??

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this should be addressed in a separate PR

// the markNeedsPaint above will never cause the composition callback to
// fire and we could miss a hide event. This schedule will get
// over-written by subsequent updates in paint, if paint is called.
_scheduleUpdate(null, semanticBounds);
Copy link
Member

Choose a reason for hiding this comment

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

Would the RenderSliverVisibilityDetector subclass have to substitute in something else for semanticBounds here? (It does so when this is called for regular paint)

Copy link
Member

Choose a reason for hiding this comment

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

(could be more motivation to do the paint refactoring I mentioned elsewhere)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done - removed the parameter from this entirely and just have an abstract Rect get bounds on the mixin.

// also ensures that they don't mutate the widget tree while we're in the
// middle of a frame.
if (isFirstUpdate) {
// We're about to render a frame, so a post-frame callback is guaranteed
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we could assert that "we're about to render a frame" just to make sure that there is no code pass where this doesn't get invoked.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure how we can do that - we might, for example, be in the middle of a frame and that still means this callback will fire, but hasScheduledFrame will be false.

This comment is legacy from the layer code. I suppose I could delete it?

_lastVisibility.remove(key);
}

onVisibilityChanged?.call(info);
Copy link
Member

Choose a reason for hiding this comment

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

The VisibilityInfo object contains the newly added screenRect value. It appears that there is a code pass where the screenRect would change, but the onVisibilityChanged is not called (because of the early return above when matchesVisibility is true) to inform the owner about the callback about it, leaving them with potential outdated information. This could be surprising.

Not sure what the motivation was to add screenRect to that data class, but having an outdated value there could be a source of future bugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahhh, I wanted to get rid of a global static dictionary.

The only thing that uses these are unit tests in this library. I suppose I could just drop that entirely. Otherwise we'd have to emit changes when just the screen rect changes, which maybe is fine but maybe is a small change in behavior that will cause problems for google consumers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So a case where I think you're right: if the widget resizes, but the resizing happens outside of the current clip. The visible bounds won't change but the widget bounds would.

I'm going to remove this as it's only used in tests.

Copy link
Member

Choose a reason for hiding this comment

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

This remains my main concern as I can already see how people will rely on this and then get surprised if it doesn't update properly. I'd either remove this feature alltogether, or - if it is only used in test - make it a debug only feature that throws in release when accessed.

@override
bool get alwaysNeedsCompositing => onVisibilityChanged != null;
void dispose() {
_compositionCallbackCanceller?.call();
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to "forget" this RO in dispose so the visibility logic is no longer triggered for it?

Copy link
Member

Choose a reason for hiding this comment

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

Ah, never mind. You do want them to fire one more time after it got disposed it seems... Odd.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, that may be our only chance to let clients know that the RO is no longer visible (assuming it ever was). There are tests that fail if you add a forget here.

@dnfield dnfield requested a review from xster May 24, 2022 03:27
@dnfield
Copy link
Contributor Author

dnfield commented May 24, 2022

@jamesderlin I'm also interested in any feedback you have, if you have any to share :)

@dnfield dnfield requested a review from goderbauer May 24, 2022 04:35
@dnfield
Copy link
Contributor Author

dnfield commented May 24, 2022

@goderbauer the suggestion to initialize _onVisibilityChanged doesn't work because it's a field on the mixin, and it won't compile.

Unfortunately, CI is not set up for this repo...

Copy link
Member

@goderbauer goderbauer left a comment

Choose a reason for hiding this comment

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

The screenRect property is still of concern to me. Everything else is just nits..

// Remove all cached data so that we won't fire visibility callbacks when
// a timer expires or get stale old information the next time around.
forget(key);
_lastVisibility.remove(key);
Copy link
Member

Choose a reason for hiding this comment

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

Wondering, why does forget not also clear the lastVisibility for the key? Is there a use case where you want to forget, but still keep that historical information?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moving this to forget

// the markNeedsPaint above will never cause the composition callback to
// fire and we could miss a hide event. This schedule will get
// over-written by subsequent updates in paint, if paint is called.
_scheduleUpdate(null);
Copy link
Member

Choose a reason for hiding this comment

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

nit: maybe make the parameters a required optional one so this call side reads a little better...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Going to make it an optional optional :)

/// The number of times the schedule update callback has been invoked from
/// [Layer.addCompositionCallback].
///
/// This is used for testing, and always returns 0 outside of debug mode.
Copy link
Member

Choose a reason for hiding this comment

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

nit: change 0 to null?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment on lines +277 to +279
}) : super(sliver) {
_onVisibilityChanged = onVisibilityChanged;
}
Copy link
Member

Choose a reason for hiding this comment

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

similar to above:

Suggested change
}) : super(sliver) {
_onVisibilityChanged = onVisibilityChanged;
}
}) : assert(key != null),
_onVisibilityChanged = onVisibilityChanged,
super(child);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Doesn't work - because the private field is on a mixin. Maybe this is a dart bug?

Copy link
Member

Choose a reason for hiding this comment

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

I see. Interesting.
Maybe add the assert, though just for symmetry with the other constructor above..

_lastVisibility.remove(key);
}

onVisibilityChanged?.call(info);
Copy link
Member

Choose a reason for hiding this comment

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

This remains my main concern as I can already see how people will rely on this and then get surprised if it doesn't update properly. I'd either remove this feature alltogether, or - if it is only used in test - make it a debug only feature that throws in release when accessed.

@goderbauer
Copy link
Member

Unfortunately, CI is not set up for this repo...

That IS unfortunate. Might be time to set that up?

Copy link
Member

@goderbauer goderbauer left a comment

Choose a reason for hiding this comment

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

With screenRect removed, this LGTM.

@jamesderlin
Copy link
Collaborator

Unfortunately, CI is not set up for this repo...

That IS unfortunate. Might be time to set that up?

That's #364.

@dnfield
Copy link
Contributor Author

dnfield commented May 24, 2022

Ahh actions aren't enabled. I'll revert my commit.

dnfield added 2 commits May 24, 2022 09:30
This reverts commit 1ba8777.
@xster xster merged commit 9aeb223 into google:master May 24, 2022
@dnfield dnfield deleted the ro branch May 26, 2022 02:50
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.

5 participants