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

Delay calculation of SED until it is actually needed. #1245

Merged
merged 14 commits into from
Sep 12, 2023
Merged

Conversation

rmjarvis
Copy link
Member

I was looking through the profile Jim made recently of an imSim run, which inspired the combine_wave_list speed up in #1243. This PR continues in that vein with various optimizations, mostly related to the SEDs.

  • First I realized that for faint objects (which there are many in typical imSim runs!), a lot of those SED calculations end up completely wasted, since their SEDs get replaced with a trivial SED later anyway. I fixed this by making the SED a property rather than an attribute. That way if it ends up getting replaced, we never need to do those SED calculations at all. Of course, in regular usage, it's not much of an optimization, since the same calculation gets done at some point, just not in the constructor, but for faint things in imSim, it definitely makes a big difference.
  • I also realized that we were calling combine_wave_list twice whenever we do those calculations. Once internal to the SED object, and once with a separate wave_list attribute in the chromatic object. But AFAICT, there is no possible way for these two wave lists to get out of sync. The only difference between them is that the one in the ChromaticObject can sometimes have an initial 0 wave and a final np.inf wave. Other than that, they were always identical. Furthermore, those bounding values were never useful for anything. So I switched the wave_list in the ChromaticObject to be a property that just returns self.SED.wave_list. This didn't require any other adjustments in the code or tests, so I'm pretty confident it's functionally equivalent and saves half the calls to combine_wave_list.
  • The ChromaticSum class does a complicated calculation to see if it can pull out a separable piece. This requires that multiple components have exactly the same SED and exactly the same relative flux. This is an extremely contrived use case, since typically a chromatic bulge + disk would have two different SEDs. And even if there were two components that had the same SED (e.g. maybe the knots and disk), they would typically have different flux fractions, which couldn't be identified as separable. So now I switched it to always consider ChromaticSums to be not separable, which removes some moderately slow hashing of SEDs.
  • The ChromaticTransformation class also does quite a few checks that are mostly not helpful for the very common case of gsobject * sed. So I added a SimpleChromaticTransformation class that specializes for this very simple kind of chromatic object.
  • I was noticing items called <frozen importlib._bootstrap>:389(parent) in the profile, and I finally figured out that these are when import statements happen inside a function rather than at the top of a module. I guess it's when python checks to see if it needs to import anything and realizes it already has everything imported. It's not a huge amount of time, but it's wasteful. So I went through the whole code base and moved as many imports as possible out of functions to module scope. Some of these required a little care to avoid circular import patterns. But since it was a lot of lines changed with no real content, I did this directly on main. If anyone wants to look, it's commit 5388b0e. But I don't think there is anything really substantive there. Anyway, this also led to a non-trivial speed up for faint objects that don't require a lot of work, so tiny bits of overhead are noticeable.
  • Finally, this isn't an optimization, but I've always been a little annoyed by our capitalized attribute name obj.SED. So I decided while I was working on all this to change it to lowercase. (The capital one works still, but it deprecated.) This matches our prevailing style in GalSim to use lowercase attributes, even for abbreviations that are otherwise usually capitalized. (e.g. wcs, psf)

The relevant timing script is devel/time_faint_bd_gals.py. On my laptop, the time to draw 1000 galaxies went from 1.48 seconds before these changes down to 0.47 seconds after. So a factor of 3 faster, which feels pretty good to me. The remaining tall (ish) poles are initializing the knots in c++, multiplying the sed by a scalar to get the final flux right, making the non-chromatic Transformation object, and initializing the SED objects. Here is the full profile now, fwiw

Time for 1000 iterations of draw_faint = 0.47057316699999996
         2083935 function calls (1973139 primitive calls) in 0.769 seconds

   Ordered by: internal time
   List reduced from 344 to 30 due to restriction <30>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
      959    0.042    0.000    0.074    0.000 /Users/Mike/GalSim/galsim/knots.py:134(_sbp)
65602/35898    0.025    0.000    0.199    0.000 /Users/Mike/GalSim/galsim/_utilities.py:56(__get__)
    10713    0.024    0.000    0.090    0.000 /Users/Mike/GalSim/galsim/sed.py:525(_mul_scalar)
    10774    0.020    0.000    0.054    0.000 /Users/Mike/GalSim/galsim/transform.py:197(__init__)
    10713    0.017    0.000    0.047    0.000 /Users/Mike/GalSim/galsim/sed.py:133(__init__)
     4063    0.016    0.000    0.018    0.000 /Users/Mike/GalSim/galsim/photon_array.py:546(_pa)
4063/1918    0.015    0.000    0.086    0.000 /Users/Mike/GalSim/galsim/transform.py:585(_shoot)
   217331    0.013    0.000    0.013    0.000 {built-in method builtins.isinstance}
     9314    0.013    0.000    0.013    0.000 {method 'reduce' of 'numpy.ufunc' objects}
     4567    0.013    0.000    0.013    0.000 /Users/Mike/GalSim/galsim/table.py:170(_tab)
     1000    0.012    0.000    0.769    0.001 time_faint_bd_gals.py:52(draw_faint)
     8076    0.011    0.000    0.021    0.000 /Users/Mike/GalSim/galsim/table.py:197(__call__)
    36202    0.011    0.000    0.011    0.000 /Users/Mike/GalSim/galsim/_utilities.py:316(__init__)
    10713    0.011    0.000    0.021    0.000 /Users/Mike/GalSim/galsim/sed.py:238(_setup_funcs)
    12479    0.011    0.000    0.015    0.000 /Users/Mike/GalSim/galsim/table.py:514(_LookupTable)
    13651    0.010    0.000    0.014    0.000 /Users/Mike/GalSim/galsim/position.py:81(_parse_args)
     9382    0.010    0.000    0.049    0.000 /Users/Mike/GalSim/galsim/transform.py:40(Transform)
      959    0.010    0.000    0.366    0.000 /Users/Mike/GalSim/galsim/chromatic.py:406(drawImage)
     7193    0.010    0.000    0.037    0.000 /Users/Mike/GalSim/galsim/sed.py:418(_call_fast)
     1918    0.009    0.000    0.208    0.000 /Users/Mike/GalSim/galsim/gsobject.py:1273(drawImage)
     1842    0.008    0.000    0.046    0.000 /Users/Mike/GalSim/galsim/table.py:310(integrate_product)
16530/13805    0.008    0.000    0.050    0.000 {built-in method numpy.core._multiarray_umath.implement_array_function}
    81602    0.008    0.000    0.008    0.000 {built-in method builtins.setattr}
     9086    0.008    0.000    0.025    0.000 /Users/Mike/mambaforge/envs/py3.8/lib/python3.8/site-packages/numpy/core/fromnumeric.py:69(_wrapreduction)
     3959    0.008    0.000    0.009    0.000 /Users/Mike/GalSim/galsim/random.py:157(duplicate)
      959    0.008    0.000    0.063    0.000 /Users/Mike/GalSim/galsim/sum.py:312(_shoot)
    10713    0.008    0.000    0.101    0.000 /Users/Mike/GalSim/galsim/sed.py:551(__mul__)
    15432    0.007    0.000    0.007    0.000 {built-in method numpy.array}
    22156    0.007    0.000    0.011    0.000 /Users/Mike/GalSim/galsim/gsparams.py:180(check)
    10713    0.007    0.000    0.126    0.000 /Users/Mike/GalSim/galsim/chromatic.py:2067(sed)

(The __get__ call in the second line is our lazy_property decorator, so it's just farming out to various other things and then writing the result as an attribute.) Nothing jumps out as particularly egregious now.

Copy link
Member

@jmeyers314 jmeyers314 left a comment

Choose a reason for hiding this comment

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

Mostly looks good. I had a few questions though.

flux_ratio = self._flux_ratio(wave)
return self._jac, self._offset, flux_ratio

def _shoot(self, photons, rng):
Copy link
Member

Choose a reason for hiding this comment

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

Does this get the Poisson statistics right? If the SED is red, then we want more red photons not weightier red photons, no?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hm. Good point I'm pretty sure this was just the normal ChromaticTransformation._shoot function stripped of the parts that weren't relevant. So I think that one also has the wrong noise properties. It's quite possible others do as well.

I'll see if I can think of a good unit test for this that we can run on all the chromatic objects to see which ones break the expected Poisson statistics. It might not be possible to get it perfect for non-separable chromatic profiles. But we can at least get it right for the separable ones.

In any case, the right implementation for this one is, as you say, to not rescale the fluxes, but just assign wavelengths the way WavelengthSampler does.

Copy link
Member Author

Choose a reason for hiding this comment

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

So, I looked into this a bit, and this function only gets used for PSFs (i.e. dimensionless chromatic objects). The base separable galaxy object with an SED does use the WavelengthSampler already. But then the PSFs are applied as photon operators, which act on the photons as they come in. So if one of them has a wavelength dependent flux scaling (not sure how that would arise physically, but we allow it), then it scales the flux values of the photons as requested. I think this is probably right. It's just a weird edge case that probably doesn't happen much in practice.

Copy link
Member

Choose a reason for hiding this comment

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

Sounds good. If this is only for PSFs (sounds right) then yeah, I don't see how this could ever even reasonably get triggered. Even chromatic PSFs ought to have flux=1 at every wavelength I think. Thanks for taking a look.

self.wave_list = obj.wave_list

@property
def sed(self):
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't deconvolved_sed = 1/original_sed like the original? Seems like there may be a missing round-trip sort of unit test here.

self.wave_list = obj.wave_list

@property
def sed(self):
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, isn't sqrt_sed = sqrt(original_sed) ?

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 catch on these two. This was a sloppy copy/paste error. Clearly we're missing a unit test that checks this.

@@ -435,7 +435,7 @@ def interpolated(self): return False
@property
def deinterpolated(self): return self
@property
def SED(self):
def sed(self):
Copy link
Member

Choose a reason for hiding this comment

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

I think we need a deprecated uppercase SED here.

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess so. I don't think users ever probably duck type GSObjects as a ChromaticObject. That's more of a convenience for us in implementation. But it doesn't hurt to add.

@jmeyers314
Copy link
Member

Actually, I have one more thing while we're here. I think the following really ought to work, but it doesn't:

sed = galsim.SED(
    galsim.LookupTable(
        [0, 621, 622, 623, np.inf],
        # [0, 621, 622, 623, 10000],
        [0, 0, 1, 0, 0],
        interpolant='linear'
    ),
    wave_type='nm',
    flux_type='fphotons'
)

bp = galsim.Bandpass("LSST_r.dat", 'nm')  
print(sed.calculateFlux(bp))  # gives nan

If I swap the np.inf for 10000 though, it does seem to work. Something about Table::integrateProduct I suspect, though I didn't dive in.

@rmjarvis rmjarvis merged commit 990aecd into main Sep 12, 2023
9 checks passed
@rmjarvis rmjarvis deleted the delay_sed branch September 12, 2023 04:03
@rmjarvis rmjarvis added this to the v2.5 milestone Oct 12, 2023
@rmjarvis rmjarvis added chromatic Related to the Chromatic classes, SEDs, bandpasses, etc. optimization/performance Related to the speed and/or memory consumption of some aspect of the code labels Oct 12, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
chromatic Related to the Chromatic classes, SEDs, bandpasses, etc. optimization/performance Related to the speed and/or memory consumption of some aspect of the code
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants