Skip to content

Conversation

@pp-mo
Copy link
Member

@pp-mo pp-mo commented Jun 14, 2017

Addresses #2586

Copy link
Member

@corinnebosley corinnebosley left a comment

Choose a reason for hiding this comment

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

Very recently made these changes to exactly the same code section. Tests added at that time should cover this PR as well.

Provided those recently-added tests pass, I am happy with this change.

@pp-mo
Copy link
Member Author

pp-mo commented Jun 14, 2017

Very recently made these changes ...

Yes, I also removed the line nd_values = np.array(nd_values) from the _nd_bounds method.
Which is extremely important in this context !

@pp-mo
Copy link
Member Author

pp-mo commented Jun 15, 2017

Errors have come in because some factory calculations are not viable on dask arrays.

Please wait for me to fix !*

@pp-mo
Copy link
Member Author

pp-mo commented Jun 22, 2017

I think this is finally sorted.
There are many commits, and much to+fro getting this done and fixing outstanding problems it uncovered (!)
As a summary:

  • fixed basic factory _nd_points + _nd_bounds methods to always yield lazy arrays, even if core data is real : This is the key bit that was required to ensure deferred calculation of derived coords even with real source data, which caused some noticeable slownesses. : LazyArrays used to do this for us, now use Dask, always, for same effect
  • rewrote some _derive methods to ensure all those calcs are now "dask capable"
  • added integration tests for OceanSigmaZFactory, covering various usage not handled by the existing unit testing : data with a time dimension; additional non-derived dimensions; awkward transpositions.
  • removed _shape and _dtype methods : no longer needed since Dask replaced LazyArray
  • fixed several usages of array.reshape(list(array.shape).append(1)) which was plain wrong (!!)
  • fixed behaviour testing for nbounds mismatch in iris.tests.test_hybrid, previously wrong due to the above bug
  • accepted a few more NaN-->masked changes in CMLs

@pp-mo
Copy link
Member Author

pp-mo commented Jun 22, 2017

STATUS:
It's good for review now 😉

Currently waiting a fix for testing problems since we provided numpy 1.13.
Will rebase when that is done.

I will also get on + post some info on the performance improvement that motivated this ...

@bjlittle bjlittle self-assigned this Jun 22, 2017
@bjlittle
Copy link
Member

@pp-mo See #2616 😉

shape[i] = size
return shape

def _dtype(self, arrays_by_key, **other_args):
Copy link
Member

Choose a reason for hiding this comment

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

@pp-mo 😱 Oh my gosh ... this isn't used anywhere! Who knows when it was relevant and useful ... good spot!

Copy link
Member Author

Choose a reason for hiding this comment

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

Both _shape and _dtype were needed when making LazyArray-s, so you could tell it what the shape and type the result would be. Biggus or dask does all that for you, of course.

nd_values_by_key[key] = nd_values
return nd_values_by_key

def _shape(self, nd_values_by_key):
Copy link
Member

Choose a reason for hiding this comment

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

@pp-mo This was only used in OceanSigmaZFactory.make_coord(), so you must have refactored that away ...

Copy link
Member Author

Choose a reason for hiding this comment

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

Effectively the same calc now exists inside OceanSigmaZFactory._derive(), but now it is done by making an array of all the dependency shapes + taking max size over each dim.

transpose_order = [pair[0] for pair in sorted_pairs] + [len(dims)]
bounds = coord.core_bounds()
bounds = coord.lazy_bounds()
if dims:
Copy link
Member

@bjlittle bjlittle Jun 22, 2017

Choose a reason for hiding this comment

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

@pp-mo There is no equivalent check here as per line 221 to check for a nop transpose, given an increasing dims order ... is it relevant to add that here also? Since we're in this space ...

Copy link
Member Author

Choose a reason for hiding this comment

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

Ok.

orography = orography_pts.reshape(
orography_pts_shape.append(1))
bds_shape = list(orography_pts.shape) + [1]
orography = orography_pts.reshape(bds_shape)
Copy link
Member

@bjlittle bjlittle Jun 22, 2017

Choose a reason for hiding this comment

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

@pp-mo Not your doing, but ... the comment on line 408 is no longer relevant. We don't have closures anymore now that we've purged the _LazyArray implementation ... could you please remove all such closure related comments, as they are no longer correct, thanks!

Copy link
Member Author

Choose a reason for hiding this comment

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

Personally I never liked all these called-only-once functions at all.
So given your prompt I've just removed them all -- hope that suits!

nsigma_slice[index] = slice(0, int(nd_points_by_key['nsigma']))
nsigma_slice = tuple(nsigma_slice)

nsigma, = nd_points_by_key['nsigma']
Copy link
Member

@bjlittle bjlittle Jun 22, 2017

Choose a reason for hiding this comment

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

@pp-mo Subtle unpacking using nsigma, ... but [nsigma] = nd_points_by_key['nsigma'] is an alternative, less subtle pattern ... you're choice, it's a minor point.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good spot, I do prefer.

nd_points_by_key['zlev'],
points_shape,
nsigma_slice)
nsigma,
Copy link
Member

Choose a reason for hiding this comment

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

@pp-mo You could just pass in nd_points_by_key['nsigma'] and do away with the local nsigma ... why did you unpack it? Just for convenience? I can only see it being used on line 866 below ...

Copy link
Member Author

Choose a reason for hiding this comment

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

Because I wanted it to be seen as an "extra" argument, i.e. a normal Python value, and not another dependency array.

zlev, nsigma, coord_dims_func):
# Calculate the index of the 'z' dimension in the inputs.
# Get the cube dimension...
i_levels_cubedim, = coord_dims_func(self.dependencies['zlev'])
Copy link
Member

Choose a reason for hiding this comment

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

@pp-mo Again [i_levels_cubedim] rather than i_levels_cubedim, ... your choice.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes!

@pp-mo
Copy link
Member Author

pp-mo commented Jun 22, 2017

Here's the performance demo.

First I generated some hybrid-height data regridded from iris.tests.stock.realistic_4d to 5* higher horizontal resolutions, getting a cube of dims (time:6, level=70, y=500, x=500).
(Not as easy as it sounded ! See : https://gist.github.com/pp-mo/f393cc2be06d0b457e5dd114a5d8b6e3 )

Then run :

import datetime
import sys

import iris


if __name__ == '__main__':
    # Get the data.
    file_path = 'big_hybrid_height_cube.nc'
    cube = iris.load_cube(file_path)

    print 'cube shape = ', cube.shape

    dependency_names = ('atmosphere_hybrid_height_coordinate',
                        'sigma',
                        'surface_altitude')

    def test():
        for coord_name in dependency_names:
            coord = cube.coord(coord_name)
            print '  {} coord is "{}"'.format(
                coord.name(),
                'lazy' if coord.has_lazy_points() else 'real')

        # Time the altitude calculation.
        start_time = datetime.datetime.now()
        alts = cube.coord('altitude')
        stop_time = datetime.datetime.now()
        time_taken = (stop_time - start_time).total_seconds()
        print '  TIMED: altitude coordinate fetch took {} secs.'.format(time_taken)

    print 'With lazy dependency coords ...'
    test()

    # Realise the aux data.
    for coord in cube.aux_coords:
        _ = coord.points
        _ = coord.bounds

    print 'With realised coords ...'
    test()

Results:

$ git checkout master
$ python hybrid_height_timing.py 
cube shape =  (6, 70, 500, 500)
With lazy dependency coords ...
  atmosphere_hybrid_height_coordinate coord is "lazy"
  sigma coord is "lazy"
  surface_altitude coord is "lazy"
  TIMED: altitude coordinate fetch took 0.010326 secs.
With realised coords ...
  atmosphere_hybrid_height_coordinate coord is "real"
  sigma coord is "real"
  surface_altitude coord is "real"
  TIMED: altitude coordinate fetch took 0.752618 secs.

$ git checkout derived_coords_lazycalc
$ python hybrid_height_timing.py 
cube shape =  (6, 70, 500, 500)
With lazy dependency coords ...
  atmosphere_hybrid_height_coordinate coord is "lazy"
  sigma coord is "lazy"
  surface_altitude coord is "lazy"
  TIMED: altitude coordinate fetch took 0.010303 secs.
With realised coords ...
  atmosphere_hybrid_height_coordinate coord is "real"
  sigma coord is "real"
  surface_altitude coord is "real"
  TIMED: altitude coordinate fetch took 0.014665 secs.

itpp@eld238: /home/h05/itpp/git/iris/iris_main
$ 

CONTEXT NOTE:
The problem is with getting a derived coord (e.g. 'altitude') when all underlying dependencies have real data ...
Old code (current master) works with "coord.core_xxx()", so it calculates when creating the derived coord. New code (here) uses "coord.lazy_xxx()", so calculation is always deferred.

So in this case, the delay normally only happens after it has fetched the derived coords data.

When all dependencies are realised, you get a delay every time you fetch cube.coord('altitude'), e.g. if you print the cube.

derived_cubedims = self.derived_dims(coord_dims_func)
i_levels_dim = i_levels_cubedim - sum(
i_dim not in derived_cubedims
for i_dim in range(i_levels_cubedim))
Copy link
Member

Choose a reason for hiding this comment

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

@pp-mo Would you buy into renaming i_levels_cubedim to zlev_dim, and i_levels_dim to zlev_index or zlev_offset.

There's a lot going on here, and using i_levels_... feels (to me) like its one level if indirection that could be avoided ...

Copy link
Member Author

@pp-mo pp-mo Jun 22, 2017

Choose a reason for hiding this comment

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

That naming was an attempt to distinguish between the dependency arguments, which are all arrays of the same dimensionality, and the "extra" args which as ordinary Python values.
Hence the "i_" -- it means 'integer', not indirection.

The problem is, we have two contexts for 'dimension' or 'index' : The original cube, and the dependency arguments. That's why I need to calculate 'i_levels_dim' from 'i_levels_cubedim'.

I'll think on ...

Copy link
Member

Choose a reason for hiding this comment

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

Isn't .index your friend here?

Copy link
Member Author

@pp-mo pp-mo Jun 23, 2017

Choose a reason for hiding this comment

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

Isn't .index your friend here?

Yes, thanks.
I've now returned to that, the way it was done in the original code.

[el.shape
for el in (sigma, eta, depth, depth_c, zlev)
if el.ndim])
result_shape = list(np.max(allshapes, axis=0))
Copy link
Member

Choose a reason for hiding this comment

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

@pp-mo This only works if all the elements have the same number of shape dimensions ... which must be the case, right? ... given the output from _remap and _remap_with_bounds, which aligns the dimensionality of everything or injects 0-d scalars for missing coordinates.

Just looking for re-assurance (and convince me otherwise) but should we ensure that allshapes has equal length elements before doing the np.max ?

Copy link
Member Author

@pp-mo pp-mo Jun 23, 2017

Choose a reason for hiding this comment

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

Yes, I believe it is guaranteed by _nd_points and _nd_bounds that the results have all-same dimensions, given they are called with the same 'ndim'.

I will try to amend some docstrings (_nd_xxx and _remap_xxx) to make this clearer : it's obvious that _remap_xxx should have docstrings reallly ...


nsigma_levs = eta + sigma * (da.minimum(depth_c, depth) + eta)
# Expand to full shape, as it may sometimes have lower dimensionality.
ones_full_result = np.ones(result_shape, dtype=np.int16)
Copy link
Member

Choose a reason for hiding this comment

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

@pp-mo Can't we use nsigma_levs.dtype here instead of np.int16 ...

Copy link
Member

@bjlittle bjlittle Jun 22, 2017

Choose a reason for hiding this comment

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

Also, shouldn't this be da.ones(...) ? To keep all things lazy ...

Copy link
Member Author

Choose a reason for hiding this comment

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

shouldn't this be da.ones(...) ? To keep all things lazy

I thought I'd only use dask where needed, and I thought it wasn't here because "nsigma_levs" is always lazy, so result is "dask * numpy". However, you just reminded me that this could be creating a large real array, so I'll change !!

Copy link
Member Author

Choose a reason for hiding this comment

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

Can't we use nsigma_levs.dtype here instead of np.int16

Yes, we can, probably better...

# Expand to full shape, as it may sometimes have lower dimensionality.
ones_full_result = np.ones(result_shape, dtype=np.int16)
ones_nsigma_result = ones_full_result[z_slices_nsigma]
result_nsigma_levs = nsigma_levs * ones_nsigma_result
Copy link
Member

@bjlittle bjlittle Jun 22, 2017

Choose a reason for hiding this comment

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

@pp-mo This is getting a tad abstract ... but why is there a need to do nsigma_levs * ones_nsigma_result ?

Is it not sufficient just to do:

nsigma_levs = eta + sigma * (da.minimum(depth_c, depth) + eta)
zlev = zlev * da.ones(result_shape, dtype=nsigma_levs.dtype)
result = da.concatenate([nsigma_levs, zlev[z_slices_rest]], axis=i_levels_dim)

Copy link
Member Author

@pp-mo pp-mo Jun 23, 2017

Choose a reason for hiding this comment

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

Is it not sufficient just to do

No, because of the way it has to work with possibly-missing dependencies.

From the CF equation:

k <= nsigma::  z(n,k,j,i) = eta(n,j,i) + sigma(k)*(min(depth_c,depth(j,i))+eta(n,j,i))
k > nsigma::   z(n,k,j,i) = zlev(k)

From _check_dependencies, we always have zlev but maybe only one of sigma or eta.
Thus we always have a 'k' (vertical) dimension in the dependency dims (but not always any 'n' (time) dimension).

If sigma is missing, then the "main" nsigma_levs calculation yields just 'eta(n, 1, j, i)'
-- the 'k' dimension is a 1 because it isn't present in the original eta array.
For the concatenation, this must get "replicated" up to (n, nsigma, j, i).
If not, in numpy you get a different (wrong) result shape
-- and in dask that causes an actual error.
The original code had the assignment result[nsigma_slice] = , which does the right thing "automatically", by broadcasting.

@bjlittle
Copy link
Member

@pp-mo Okay, I'm done for now ... over to you!

@pp-mo
Copy link
Member Author

pp-mo commented Jun 23, 2017

@bjlittle thanks for all the attention !
I'm hoping the latest commit addresses all your earlier points.
This will presumably need at least respinning, possibly rebasing, to get tests passing following #2616

@bjlittle bjlittle added this to the v2.0 milestone Jun 23, 2017
Copy link
Member

@bjlittle bjlittle left a comment

Choose a reason for hiding this comment

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

@pp-mo Awesome effort! Thanks for sticking with it!

I'm 👍 for the PR, just rebase once #2616 is merged, and then we can squash and merge!

@pp-mo pp-mo force-pushed the derived_coords_lazycalc branch from 01bd13d to 8dab554 Compare June 26, 2017 09:35
@pp-mo
Copy link
Member Author

pp-mo commented Jun 26, 2017

@bjlittle just rebase once #2616 is merged

Done that, let's see if it passes ... 🤞

@pp-mo pp-mo force-pushed the derived_coords_lazycalc branch from 507a70f to 3a4a536 Compare June 27, 2017 13:59
@pp-mo
Copy link
Member Author

pp-mo commented Jun 27, 2017

"Python3 deprecation undermining warnings tests"

That was a bit nasty.
Test code like this was failing in Python 3.5 ...

msg = 'sample text'
with self.assertRaisesRegexp(UserWarning, msg):
    warnings.simple_filter('error')
    call()

... because assertRaisesRegexp() is deprecated in favour of assertRaisesRegex() (with no final "p").
So it was getting the deprecation warning instead of the intended warning.

@pp-mo
Copy link
Member Author

pp-mo commented Jun 27, 2017

At last it passes 🥂
-after many, many, many re-spins for Travis failures ☹️

@pelson are you happy with this, in absence of @bjlittle ?
I really don't want to have to touch this again!

@pelson
Copy link
Member

pelson commented Jun 28, 2017

@pp-mo - Given the spin-up cost, I honestly can't justify the effort of me merging this today. I completely appreciate your desire for it not to go stale, and am hopeful that @bjlittle will be able to merge tomorrow when he returns. If not, I'll clear a few hours and try to get the ball rolling on it myself in the next few days. Work for you?

@corinnebosley
Copy link
Member

@pp-mo I can review this. Just give me a bit of time to check what you have done since I was last in.

Copy link
Member

@corinnebosley corinnebosley left a comment

Choose a reason for hiding this comment

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

Aside from wanting to know what the slices tuple whatever does, I am happy with this.

# Make a slice tuple to index the remaining z-levels.
z_slices_rest = [slice(None)] * ndims
z_slices_rest[z_dim] = slice(int(nsigma), None)
z_slices_rest = tuple(z_slices_rest)
Copy link
Member

Choose a reason for hiding this comment

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

Could you please explain to me what this bit (L.814 to L.820) does? I don't understand.

Copy link
Member Author

Choose a reason for hiding this comment

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

The z_slices_nsigma thing is a tuple of keys to extract the first 'nsigma' z-levels,
i.e. data[z_slices_nsigma] would be something line data[:, ..., :, :nsigma, :, ..., :]
- recall that here, 'nsigma' is just a number.

This is exactly what in the original code is called nsigma_slice.
I changed over to calculating that within the _derive call, instead of passing in in from the make_coord routine, as I thought it was much clearer to have derivation + use of it in the same place.

Meanwhile, the subsequent bit z_slices_rest is to select the "remaining" z levels, i.e. those beyond the first nsigma :
that is, if data[z_slices_nsigma] does something like data[:, ..., :, 0:nsigma, :, ..., :],
then data[z_slices_rest] is like data[:, ..., :, nsigma:-1, :, ..., :],
In the new calc, we need to extract over the 'remaining' levels to get the concatenate right.

Is that any clearer ?

@corinnebosley
Copy link
Member

@pp-mo What do you want me to do about this? I am happy to merge, but I can leave it if you would like someone more qualified to check it over first.

@pp-mo
Copy link
Member Author

pp-mo commented Jun 28, 2017

Hi @corinnebosley sorry for slow response -- I've been at a meeting most of this morning.

I am happy to merge, but I can leave it if you would like someone more qualified to check it over first.

I think @bjlittle provisionally approved this anyway according #2604 (review)

That should cover everything except the latest commit, which fixed the warning/error testing bug.

@corinnebosley
Copy link
Member

@pp-mo I thought that might have been the case, but I checked over everything anyway and couldn't spot any issues. Bearing that in mind, and given that the tests are passing, I will merge this in one hour (at 15:18) unless I get any objections in the meantime.

@corinnebosley corinnebosley merged commit 192f26f into SciTools:master Jun 28, 2017
@corinnebosley
Copy link
Member

@pp-mo Boom!

@pp-mo
Copy link
Member Author

pp-mo commented Jun 28, 2017

@corinnebosley thanks !

marqh pushed a commit to marqh/iris that referenced this pull request Jul 14, 2017
…ools#2604)

* Integration tests for OceanSigmaZFactory -- lazy cases not currently working.

* Refactor osz (not yet lazy).

* Add test with extra cube dims.

* Fixed calculation; all working except lazy integration tests.

* Enable all-lazy operation; all tests working.

* Fix nasty misuses of list.append.

* Adjust testing for fixes to aux_factory code.

* CML changes: missing altitude points NaN --> mask.

* Clarify need for some integration testcases.

* Review changes.

* Reroute assertRaisesRegexp to prevent Python3 deprecation undermining warnings tests.
@pp-mo pp-mo deleted the derived_coords_lazycalc branch March 18, 2022 15:41
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.

4 participants