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

A start on linting the sprite module #1925

Merged
merged 11 commits into from Jun 27, 2020
Merged

Conversation

MyreMylar
Copy link
Contributor

@MyreMylar MyreMylar commented Jun 5, 2020

Warnings disabled = 5:

  1. layer=None # noqa pylint: disable=unused-argument; supporting legacy derived classes that override in non-pythonic way
  2. return self.__class__(self.sprites()) # noqa pylint: disable=too-many-function-args; needed because copy() won't work on AbstractGroup
  3. class collide_circle_ratio(object): # noqa pylint: disable=invalid-name; this is a function-like class
  4. class collide_rect_ratio: # noqa pylint: disable=invalid-name; this is a function-like class
  5. def draw(self, surface, bgd=None): # noqa pylint: disable=arguments-differ; unable to change public interface

Remaining issues after this PR:

************* Module src_py.sprite
src_py\sprite.py:1:0: C0302: Too many lines in module (1735/1000) (too-many-lines)
src_py\sprite.py:69:2: W0511: TODO:  a group that holds only the 'n' most recent elements. (fixme)
src_py\sprite.py:1097:4: R0914: Too many local variables (16/15) (too-many-locals)
------------------------------------------------------------------
Your code has been rated at 9.95/10 (previous run: 9.97/10, +0.00)

Apart from the TODO which I'm ignoring, the remaining issues are:

  1. The module is too large. This is a historic problem. If I was coding it from the beginning i'd probably put all the classes into separate files (making them technically mini-modules) and then pull them all together into a sprite module with an __init__.py or something. This isn't pygame's general coding style for modules though so I'm not sure whether we should just leave it as it is and disable the warning or divide up the module in some other way...
  2. too many locals in LayeredDirty's draw function. I've left this error in for now in case someone wants to revisit this method again in the future. The problem is that having lots of local variables actually speed this function up a bit so while it is relatively easy to get rid of them it costs in performance. However, there may be some broader issues with the whole technique switching between two drawing modes that it's hard to assess that properly right now on my main development platform (windows) because display.update() currently doesn't function in the same way it did originally in SDL 2.0.12 on windows. I also felt that re modelling this method if it was going to be done was probably an issue for a more focused PR.

@MyreMylar MyreMylar marked this pull request as ready for review June 7, 2020 15:51
@MyreMylar
Copy link
Contributor Author

If anyone has any ideas for the last two warnings I'd like them.

@illume
Copy link
Member

illume commented Jun 8, 2020

Reasons can be added after ; if you want.

class Sprite(object): # pylint: disable=useless-object-inheritance; required for python 2

src_py/sprite.py Outdated Show resolved Hide resolved
return _ret

@staticmethod
Copy link
Member

Choose a reason for hiding this comment

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

Nice improvement moving these to separate functions.

I haven't read through these two to see if they were refactored correctly however.

src_py/sprite.py Outdated Show resolved Hide resolved
src_py/sprite.py Outdated Show resolved Hide resolved
src_py/sprite.py Outdated Show resolved Hide resolved
@MyreMylar
Copy link
Contributor Author

MyreMylar commented Jun 14, 2020

  • I added back the functions I previously removed that were pulled in as local variables, a few didn't need to to return as they are now passed in to sub-functions as parameters which will also make them local variables inside the sub-function.

  • Changed the two new __repr__ methods to be like @illume suggestion.

  • Fixed the pylint disable reason comments so they actually work.

  • Disabled the draw function arguments-differ warning as well since I think that one is out of our hands.

  • Updated the first post.

To Do:

  • consider turning _layer into a property and making the variable __layer

…d-only, layer property to DirtySprite to avoid protected access
@MyreMylar
Copy link
Contributor Author

Added layer as a read-only property on Sprite and DirtySprite. This is actually consistent with the DirtySprite docs, weirdly, which seem to written as if layer existed as a read-only property already.

The property uses gettattr for dynamic property access for the spite class which doesn't define the ._layer variable normally, but it uses regular access for DirtySprite, which does.

I also edited the tests slightly because they seemed to be directly assigning values fruitlessly to layer in two places which up to now did nothing of any use and now raises an AttributeError as the property is genuinely read only rather than non-existent.

It's probable that there is other code out there right now directly assigning values to .layer which will now raise attribute errors where it didn't before; but I think that is a good thing because that code is currently not doing anything useful as the intended behaviour is that only groups should be setting the layer value of sprites.

Where the groups set the layer of a sprite they now use setattr to set the protected variable directly, I think this is the only way to keep the intended interface where only groups should set the layer property of sprites, the _layer variable is still protected and also also avoid pylint warnings.

Let me know if I'm wrong or if this is too controversial.

@illume
Copy link
Member

illume commented Jun 16, 2020

Awesome. I haven't had a chance to read it yet, but I will soon.

@illume
Copy link
Member

illume commented Jun 18, 2020

Thinking about a couple of things...

So that the layer attribute is the fast one, should we reverse which one is the property?

  • _layer as a property for backwards compatibility
  • layer as a normal attribute

Should layer be read only?

src_py/sprite.py Outdated Show resolved Hide resolved
src_py/sprite.py Outdated Show resolved Hide resolved
@illume
Copy link
Member

illume commented Jun 18, 2020

Here's some scripts to test with.

python3 -m pygame.examples.testsprite
python3 -m pygame.examples.testsprite -layered_dirty
python3 -m pygame.examples.testsprite -layered_dirty 5000
python3 -m pygame.examples.testsprite -static
python3 -m pygame.examples.testsprite -noupdate_rects
python3 -m pygame.examples.aliens
python3 -m pygame.examples.chimp
python3 -m pygame.examples.mask

There's also this https://gist.github.com/illume/41a065bf92201330666b7d8a20a634ca that plots fps over 2000 frames.

@illume
Copy link
Member

illume commented Jun 18, 2020

Ran a few tests with python 3.8... TLDR; benchmarking is hard.

timeout 20s python3 -m pygame.examples.testsprite -layered_dirty 5000

master  : 46.934449, 45.196756, 43.904627, 47.206457, 45.247934
0e42b362: 42.288920, 46.980882, 42.669770, 46.028205, 45.688433
f79e1ca2: 45.144592, 45.613644, 44.262061, 44.968407, 44.661044
5bc350d0: 43.668951, 46.474842, 46.772088, 44.964670, 47.130307
f5c3fe3a: 44.115555, 46.774415, 45.463876, 43.984351, 46.551458
d27774b7: 46.758217, 45.470631, 44.417179, 44.845107, 46.559535
a9d72d95: 45.619297, 43.508889, 45.834232, 44.131916, 45.471667

timeout 20s python3 -m pygame.examples.testsprite

master  : 577.985814, 505.740669, 505.530010, 502.663816, 496.931811, 
0e42b362: 506.633843, 509.809403, 504.556776, 500.054801, 502.694818, 
f79e1ca2: 504.688256, 505.964508, 502.701089, 499.066812, 501.611474, 
5bc350d0: 515.345082, 503.314751, 511.168869, 501.950709, 507.244544, 
f5c3fe3a: 507.359998, 496.725231, 505.442568, 506.516952, 501.029989
d27774b7: 503.614800, 507.606795, 620.665271, 502.543432, 610.389844, 
a9d72d95: 509.816967, 501.455303, 502.492399, 510.688454, 503.413216, 

Full script:

# Run it five times. Sleep inbetween so CPUs/GPUs can cool down.

timesarray=( one two three four five)
checkouts=( master 0e42b3624ba24eacf8d9b6741e330ead0a234dd3 f79e1ca226927f60730ef5f5203a06da0d1442ac 5bc350d0446fcb5b843d0c8e0af2537c414d4632 f5c3fe3afbcfaa0362ba1e304acff3d18a853406 d27774b7a57b74d05d80e82b630b6f443351c434 a9d72d9543fc83075aa5a0ab04260b6873677e3a )

for checkoutit in "${checkouts[@]}"
do

git checkout $checkoutit
python3 setup.py install 2> /dev/null > /dev/null

# So pygame loaded in file cache memory, and first run is not affected.
timeout 1s python3 -m pygame.examples.testsprite 2> /dev/null > /dev/null

for ti in "${timesarray[@]}"
do
	timeout 20s python3 -m pygame.examples.testsprite -layered_dirty 5000 2> /dev/null | grep FPS
	sleep 30
done

for ti in "${timesarray[@]}"
do
	timeout 20s python3 -m pygame.examples.testsprite 2> /dev/null | grep FPS
	sleep 30
done

sleep 30

done

@MyreMylar
Copy link
Contributor Author

Thinking about a couple of things...

So that the layer attribute is the fast one, should we reverse which one is the property?

  • _layer as a property for backwards compatibility
  • layer as a normal attribute

No, I think that would probably be more confusing because the leading underscore generally indicates a protected variable only to be used inside the class or for inherited classes. Generally users won't be using '.layer' or '._layer' in performance critical code anyway because it is designed to just be a read only indication of the sprite's layer, changing the layer variable doesn't change a sprite's actual layer in a group so there is no point setting the layer variable in a tight update loop or anything like that. Actual layer management is done through the groups which have layers and the layer property of a sprite is a convenience thing for reading the current layer of a sprite.

The whole concept of a sprite having a layer variable is a little bit odd as sprites can be in multiple groups. If a sprite is present in multiple groups with layers, on a different layer in each group - what value should the sprite's layer (.layer or ._layer) variable have? Usually it doesn't come up because people don't use multiple groups that way, but it is still a little odd.

Should layer be read only?

Yes, given the current way the code works - because otherwise it creates confusion when you set the layer of a sprite and it doesn't change layers in (one of) its groups. The restriction makes sense in the docs and given how layers actually work in the code, the change over to making it a property just enforces things. Making sure that people incorrectly assigning a value to a sprite's layer will now get a warning that what they are doing won't actually do anything. I'm still sure it'll cause someone's code somewhere to raise an error because a lot of people make a mess when they are coding.

A more dramatic change would be to change the .layer property to have a setter pick a group the sprite is assigned to (the first one? probably all of them?) and then try calling .set_layer() on it with the new layer (I guess it will silently fail if the group doesn't have a layer, because otherwise you'd get lots of errors using multiple groups in fairly normal ways). Probably pylint will moan about that though because the base group type doesn't have a .set_layer() method so it'd be another change to add that to all groups even if they don't have layers or something like that. May also break code but in different ways to the current commits. I think if I was designing it again I would have just enforced having a LayeredSprite in a LayeredSpriteGroup rather than the current situation where any sprite can go in any group, even if they don't really work quite right in that type of group properly without a bunch of hacks.

I think the way I've done it makes the most sense that can be made of it under the current code logic but these are definitely questions to ponder when (if?) there is a sprite redesign after 2.0.

@MyreMylar
Copy link
Contributor Author

Ran a few tests with python 3.8... TLDR; benchmarking is hard.

Looks like there isn't any dramatic change in performance with this benchmark which I guess is a win?

…import of Rect & get_ticks to try and speed up LayeredDirty draw a smidge
@MyreMylar
Copy link
Contributor Author

MyreMylar commented Jun 22, 2020

Testing all these:

python3 -m pygame.examples.testsprite
python3 -m pygame.examples.testsprite -layered_dirty
python3 -m pygame.examples.testsprite -layered_dirty 5000
python3 -m pygame.examples.testsprite -static
python3 -m pygame.examples.testsprite -noupdate_rects
python3 -m pygame.examples.aliens
python3 -m pygame.examples.chimp
python3 -m pygame.examples.mask

After lots of test runs they all seem basically the same except the LayeredDirty ones which seem a teeny bit slower in the new version versus the old mega function.

This one:
python3 -m pygame.examples.testsprite -layered_dirty 5000

Seems to run at about 21 FPS on average with the current mega draw() function, but at about 20 FPS on average with the new split into two methods arrangement. It's tricky to be 100% with a small change like this but it seems fairly consistent so far.

I'm not sure exactly what is causing the change so far, other than that it is in the LayeredDirty draw() method. I tried a few small things but didn't have much luck (the latest commit reintroduces a too-many-locals pylint error trying to chase that missing frame). It may be I have to abandon refactoring this one and just revert to the old slightly faster mess for now to get this checked in. I'll poke at it again tomorrow for a bit with a fresh brain and some biscuits.

Everything else seemed fine though.

@illume
Copy link
Member

illume commented Jun 23, 2020

Probably it's worth committing your cleaner version, over the messy one IMHO. If we can't detect a difference at the macro level, I think it's fine :)

On the other hand, if you want to continue... It could be worth doing a benchmark at the method level with just the one you found a bit slower. Perhaps even do some profiling to see if you notice anythin.

if not self.alive():
setattr(self, '_layer', value)
else:
raise AttributeError("Can't set layer directly after "
Copy link
Member

Choose a reason for hiding this comment

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

Did you consider changing the layer in each group here?

Something like...

[group.change_layer(self, value) for group in self.groups]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I discussed it a bit further above. One implementation difficulty is that not all types of groups have layers so the change_layer() method doesn't exist on those groups and will throw an error with an implementation like the above. A fairly common use case might be where people have some subset of sprites in a layered group, but also have all their sprites in one single basic mega-group for convenience. Probably we could work around that with a more complicated setter.

It's also a slightly more dramatic functionality change which I'm mostly steering clear of in these linting passes, and I guess we'd also need to update the docs to reflect that you can now change the layer of a sprite by setting it's layer. I'm sort of ambivalent on it for now, unless you think it's super useful?

@MyreMylar
Copy link
Contributor Author

On the other hand, if you want to continue... It could be worth doing a benchmark at the method level with just the one you found a bit slower. Perhaps even do some profiling to see if you notice anything.

I poked at this a little and noticed that right now with both the current layered_dirty tests the draw method goes down the slower rendering path which seemed wrong based on the intentions. A little investigation later showed that this was partly because get_ticks() was always returning 0.

That turned out to be because the testsprite example was not calling pygame.init(). Once that was re-enabled, it was easy to see that the layered_dirty 5000 test was now fighting constantly between the two drawing paths, as the simpler one was always faster than the threshold it would then trip the code that tells it to go down the slower path which would then trip the code that tells it to go back to the faster path. I assume at some point the default threshold values for the dirty flag technique were faster at an 80FPS threshold so this arrangement made sense but right now it doesn't, at least not on my PC.

Anyway since that was obviously bad, the easiest quick fix without tearing down the whole house is just to raise the default threshold a chunk for when it switches to the other (currently slower) path. With that change this test:

python3 -m pygame.examples.testsprite -layered_dirty 5000

Now runs twice as fast after the refactor.

I expect we should add to one of those issues discussing a new sprite class whether the Dirty flag technique is still useful at all, and if it is we need a test that demonstrates its worth because this one with current blitting speeds seems to show that so far the time spent setting it up costs more than the time saved in all circumstances, at least in SDL2. I'll have a check with SDL1 as well to see if it makes a difference.

@MyreMylar
Copy link
Contributor Author

Looks like it is always faster to use the non-dirty rect version of draw always on SDL1 as well. Interesting, think I need to go and make some changes to my gui module.

…hing to using dirty rect technique to 999 as it seems to be always slower.
@MyreMylar MyreMylar added the needs-testing This issue requires testing on one or more platforms label Jun 23, 2020
@illume
Copy link
Member

illume commented Jun 23, 2020

I've noticed bugs in the thresholding code as well. The speed of dirty/not-dirty is really dependent on the hardware and the images drawn.

The goal of that threshold code is to select the appropriate drawing mode for that scene on that hardware.

@MyreMylar
Copy link
Contributor Author

it'd be useful to get some more testing on different platforms with the default threshold change using:

python3 -m pygame.examples.testsprite -layered_dirty 5000
python3 -m pygame.examples.testsprite -layered_dirty

those sets of settings (though I personally set it up via Pycharm's Run->Edit configuration to make it work). Basically comparing 2.0.0.dev10 or master versus the latest on this branch just in case it only runs faster on windows.

To run the test you just have to let it run for a few seconds (I was counting to ten) then close the window and it should report the average FPS. For me the difference was very noticeable with current master, sdl2 config showing about 20ish FPS on the 5000 test and showing more like 40ish FPS with the latest on this branch just from the threshold change.

If you can't be bothered to sync to this branch you should get the same results by changing line 214 of testsprite.py to look like this:

        sprites = pg.sprite.LayeredDirty(_time_threshold=0)

@illume
Copy link
Member

illume commented Jun 23, 2020

Did you see there is a static option? LayeredDirty works best when there are static(non-moving) sprites. python3 -m pygame.examples.testsprite -static

I'm thinking it might be best to factor out the threshold algorithm into a function, so it can be tested easily? This would be easier than testing different apps on different platforms.

@illume
Copy link
Member

illume commented Jun 23, 2020

Note, that on windows with SDL < 2.0.13 partial updates are not implemented by SDL.

@MyreMylar
Copy link
Contributor Author

Did you see there is a static option? LayeredDirty works best when there are static(non-moving) sprites. python3 -m pygame.examples.testsprite -static

Tried running the testing again with the static flag as well. It went a bit better for the default threshold (i.e. using DirtyFlag) but was still slightly worse than not using it. But eh, I think it is probably not the time to puzzle this out on windows with SDL 2.0.12.

I'll just leave it at what it was before.

@MyreMylar MyreMylar removed the needs-testing This issue requires testing on one or more platforms label Jun 25, 2020
@MyreMylar
Copy link
Contributor Author

MyreMylar commented Jun 25, 2020

My conclusion is that I'll stop messing with LayeredDirty's draw function. I don't think I've made it worse in any significant way performance wise (though it's hard to tell on windows). I think changing the way layers are set is potentially a decent change to sprite's functionality if it can be made to work, and having properties will certainly make it easier to do that - but probably should be in a separate focused PR that changes the docs and adds tests rather than this linting focused one.

Is there anything else important to address?

@illume
Copy link
Member

illume commented Jun 27, 2020

Some future work came our of this PR, it would be good to capture it and make issues.

  • LayeredDirty choosing dirty/non-dirty function doesn't always work. Probably the threshold stuff needs to be factored out and tested properly.
  • get_ticks always returns 0, when not pygame.init. Find a way to get this to work, without init all pygame?
  • assigning to Sprite.layer, see @layer.setter and comment A start on linting the sprite module #1925 (comment)

@illume illume added Code quality/robustness Code quality and resilience to changes sprite pygame.sprite and removed needs-review labels Jun 27, 2020
Copy link
Member

@illume illume left a comment

Choose a reason for hiding this comment

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

👍 🎉

@illume illume merged commit e43547c into pygame:master Jun 27, 2020
@MyreMylar MyreMylar deleted the pylint-sprite branch September 11, 2020 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Code quality/robustness Code quality and resilience to changes sprite pygame.sprite
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants