Skip to content

Commit 6ba5e57

Browse files
committed
Merge branch '#670' into #628
2 parents 4d66de6 + fea23ea commit 6ba5e57

File tree

5 files changed

+244
-43
lines changed

5 files changed

+244
-43
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ Bug Fixes and Improvements
108108
- Fixed a bug in rendering profiles that involve two separate shifts. (#645)
109109
- Fixed a bug if drawImage was given odd nx, ny parameters, the drawn profile
110110
was not correctly centered in the image. (#645)
111-
111+
- Added intermediate results cache to `ChromaticObject.drawImage` and
112+
`ChromaticConvolution.drawImage`, which, for some applications, can
113+
significantly speed up (anywhere from 10% to 2000%) the rendering of groups
114+
of similar (same SED, same Bandpass, same PSF) chromatic profiles.
112115

113116
Updates to config options
114117
-------------------------

galsim/chromatic.py

Lines changed: 99 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,21 @@ class ChromaticObject(object):
8484
# profile at a particular wavelength.
8585
# 2) Define a `withScaledFlux` method, which scales the flux at all wavelengths by a fixed
8686
# multiplier.
87-
# 3) Initialize a `separable` attribute. This marks whether (`separable = True`) or not
87+
# 3) Potentially define their own `__repr__` and `__str__` methods. Note that the default
88+
# assumes that `.obj` is the only attribute of significance, but this isn't always
89+
# appropriate, (e.g. ChromaticSum, ChromaticConvolution).
90+
# 4) Initialize a `separable` attribute. This marks whether (`separable = True`) or not
8891
# (`separable = False`) the given chromatic profile can be factored into a spatial profile
8992
# and a spectral profile. Separable profiles can be drawn quickly by evaluating at a single
9093
# wavelength and adjusting the flux via a (fast) 1D integral over the spectral component.
9194
# Inseparable profiles, on the other hand, need to be evaluated at multiple wavelengths
9295
# in order to draw (slow).
93-
# 4) Separable objects must initialize an `SED` attribute, which is a callable object (often a
96+
# 5) Separable objects must initialize an `SED` attribute, which is a callable object (often a
9497
# `galsim.SED` instance) that returns the _relative_ flux of the profile at a given
9598
# wavelength. (The _absolute_ flux is controlled by both the `SED` and the `.flux` attribute
9699
# of the underlying chromaticized GSObject(s). See `galsim.Chromatic` docstring for details
97100
# concerning normalization.)
98-
# 5) Initialize a `wave_list` attribute, which specifies wavelengths at which the profile (or
101+
# 6) Initialize a `wave_list` attribute, which specifies wavelengths at which the profile (or
99102
# the SED in the case of separable profiles) will be evaluated when drawing a
100103
# ChromaticObject. The type of `wave_list` should be a numpy array, and may be empty, in
101104
# which case either the Bandpass object being drawn against, or the integrator being used
@@ -105,6 +108,7 @@ class ChromaticObject(object):
105108
# attribute representing a manipulated `GSObject` or `ChromaticObject`, or an `objlist`
106109
# attribute in the case of compound classes like `ChromaticSum` and `ChromaticConvolution`.
107110

111+
108112
def __init__(self, obj):
109113
self.obj = obj
110114
if isinstance(obj, galsim.GSObject):
@@ -119,6 +123,25 @@ def __init__(self, obj):
119123
raise TypeError("Can only directly instantiate ChromaticObject with a GSObject "+
120124
"or ChromaticObject argument.")
121125

126+
@staticmethod
127+
def _get_multiplier(sed, bandpass, wave_list):
128+
wave_list = np.array(wave_list)
129+
if len(wave_list) > 0:
130+
multiplier = np.trapz(sed(wave_list) * bandpass(wave_list), wave_list)
131+
else:
132+
multiplier = galsim.integ.int1d(lambda w: sed(w) * bandpass(w),
133+
bandpass.blue_limit, bandpass.red_limit)
134+
return multiplier
135+
136+
@staticmethod
137+
def resize_multiplier_cache(maxsize):
138+
""" Resize the cache (default size=10) containing the integral over the product of an SED
139+
and a Bandpass, which is used by ChromaticObject.drawImage().
140+
141+
@param maxsize The new number of products to cache.
142+
"""
143+
ChromaticObject._multiplier_cache.resize(maxsize)
144+
122145
def __repr__(self):
123146
return 'galsim.ChromaticObject(%r)'%self.obj
124147

@@ -225,12 +248,19 @@ def drawImage(self, bandpass, image=None, integrator='trapezoidal', **kwargs):
225248
226249
By default, the above two integrators will use the trapezoidal rule for integration. The
227250
midpoint rule for integration can be specified instead by passing an integrator that has
228-
been initialized with the `rule=galsim.integ.midpt` argument. Finally, when creating a
251+
been initialized with the `rule=galsim.integ.midpt` argument. When creating a
229252
ContinuousIntegrator, the number of samples `N` is also an argument. For example:
230253
231254
>>> integrator = galsim.ContinuousIntegrator(rule=galsim.integ.midpt, N=100)
232255
>>> image = chromatic_obj.drawImage(bandpass, integrator=integrator)
233256
257+
Finally, this method uses a cache to avoid recomputing the integral over the product of
258+
the bandpass and object SED when possible (i.e., for separable profiles). Because the
259+
cache size is finite, users may find that it is more efficient when drawing many images
260+
to group images using the same SEDs and bandpasses together in order to hit the cache more
261+
often. The default cache size is 10, but may be resized using the
262+
`ChromaticObject.resize_multiplier_cache()` method.
263+
234264
@param bandpass A Bandpass object representing the filter against which to
235265
integrate.
236266
@param image Optionally, the Image to draw onto. (See GSObject.drawImage()
@@ -265,11 +295,7 @@ def drawImage(self, bandpass, image=None, integrator='trapezoidal', **kwargs):
265295
wave_list = self._getCombinedWaveList(bandpass)
266296

267297
if self.separable:
268-
if len(wave_list) > 0:
269-
multiplier = np.trapz(self.SED(wave_list) * bandpass(wave_list), wave_list)
270-
else:
271-
multiplier = galsim.integ.int1d(lambda w: self.SED(w) * bandpass(w),
272-
bandpass.blue_limit, bandpass.red_limit)
298+
multiplier = ChromaticObject._multiplier_cache(self.SED, bandpass, tuple(wave_list))
273299
prof0 *= multiplier/self.SED(bandpass.effective_wavelength)
274300
image = prof0.drawImage(image=image, **kwargs)
275301
return image
@@ -712,6 +738,9 @@ def offset_func(w):
712738

713739
return galsim.Transform(self, offset=offset)
714740

741+
ChromaticObject._multiplier_cache = galsim.utilities.LRU_Cache(
742+
ChromaticObject._get_multiplier, maxsize=10)
743+
715744

716745
class InterpolatedChromaticObject(ChromaticObject):
717746
"""A ChromaticObject that uses interpolation of predrawn images to speed up subsequent
@@ -788,8 +817,8 @@ def __init__(self, obj, waves, oversample_fac=1.0):
788817

789818
# Finally, now that we have an image scale and size, draw all the images. Note that
790819
# `no_pixel` is used (we want the object on its own, without a pixel response).
791-
self.ims = [ obj.drawImage(scale=scale, nx=im_size, ny=im_size, method='no_pixel') \
792-
for obj in objs ]
820+
self.ims = [ obj.drawImage(scale=scale, nx=im_size, ny=im_size, method='no_pixel')
821+
for obj in objs ]
793822

794823
def __repr__(self):
795824
s = 'galsim.InterpolatedChromaticObject(%r,%r'%(self.original, self.waves)
@@ -1648,6 +1677,48 @@ def __init__(self, *args, **kwargs):
16481677
for obj in self.objlist:
16491678
self.wave_list = np.union1d(self.wave_list, obj.wave_list)
16501679

1680+
@staticmethod
1681+
def _get_effective_prof(sep_SED, insep_profs, bandpass,
1682+
iimult, wave_list, wmult, integrator,
1683+
gsparams):
1684+
# Collapse inseparable profiles into one effective profile
1685+
SED = lambda w: reduce(lambda x,y:x*y, [s(w) for s in sep_SED], 1)
1686+
insep_obj = galsim.Convolve(insep_profs, gsparams=gsparams)
1687+
# Find scale at which to draw effective profile
1688+
iiscale = insep_obj.evaluateAtWavelength(bandpass.effective_wavelength).nyquistScale()
1689+
if iimult is not None:
1690+
iiscale /= iimult
1691+
# Create the effective bandpass.
1692+
wave_list = np.union1d(wave_list, bandpass.wave_list)
1693+
wave_list = wave_list[wave_list >= bandpass.blue_limit]
1694+
wave_list = wave_list[wave_list <= bandpass.red_limit]
1695+
effective_bandpass = galsim.Bandpass(
1696+
galsim.LookupTable(wave_list, bandpass(wave_list) * SED(wave_list),
1697+
interpolant='linear'))
1698+
# If there's only one inseparable profile, let it draw itself.
1699+
if len(insep_profs) == 1:
1700+
effective_prof_image = insep_profs[0].drawImage(
1701+
effective_bandpass, wmult=wmult, scale=iiscale, integrator=integrator,
1702+
method='no_pixel')
1703+
# Otherwise, use superclass ChromaticObject to draw convolution of inseparable profiles.
1704+
else:
1705+
effective_prof_image = ChromaticObject.drawImage(
1706+
insep_obj, effective_bandpass, wmult=wmult, scale=iiscale,
1707+
integrator=integrator, method='no_pixel')
1708+
1709+
effective_prof = galsim.InterpolatedImage(effective_prof_image, gsparams=gsparams)
1710+
return effective_prof
1711+
1712+
@staticmethod
1713+
def resize_effective_prof_cache(maxsize):
1714+
""" Resize the cache containing effective profiles, (i.e., wavelength-integrated products
1715+
of separable profile SEDs, inseparable profiles, and Bandpasses), which are used by
1716+
ChromaticConvolution.drawImage().
1717+
1718+
@param maxsize The new number of effective profiles to cache.
1719+
"""
1720+
ChromaticConvolution._effective_prof_cache.resize(maxsize)
1721+
16511722
def _findSED(self):
16521723
# pull out the non-trivial seds
16531724
sedlist = [ obj.SED for obj in self.objlist if obj.SED != galsim.SED('1') ]
@@ -1692,6 +1763,14 @@ def drawImage(self, bandpass, image=None, integrator='trapezoidal', iimult=None,
16921763
Works by finding sums of profiles which include separable portions, which can then be
16931764
integrated before doing any convolutions, which are pushed to the end.
16941765
1766+
This method uses a cache to avoid recomputing 'effective' profiles, which are the
1767+
wavelength-integrated products of inseparable profiles, the spectral components of
1768+
separable profiles, and the bandpass. Because the cache size is finite, users may find
1769+
that it is more efficient when drawing many images to group images using the same
1770+
SEDs, bandpasses, and inseparable profiles (generally PSFs) together in order to hit the
1771+
cache more often. The default cache size is 10, but may be resized using the
1772+
`ChromaticConvolution.resize_effective_prof_cache()` method.
1773+
16951774
@param bandpass A Bandpass object representing the filter against which to
16961775
integrate.
16971776
@param image Optionally, the Image to draw onto. (See GSObject.drawImage()
@@ -1810,42 +1889,20 @@ def drawImage(self, bandpass, image=None, integrator='trapezoidal', iimult=None,
18101889
# insep_profs should never be empty, since separable cases were farmed out to
18111890
# ChromaticObject.drawImage() above.
18121891

1813-
# Collapse inseparable profiles into one effective profile
1814-
SED = lambda w: reduce(lambda x,y:x*y, [s(w) for s in sep_SED], 1)
1815-
insep_obj = galsim.Convolve(insep_profs, gsparams=self.gsparams)
1816-
# Find scale at which to draw effective profile
1817-
iiscale = insep_obj.evaluateAtWavelength(bandpass.effective_wavelength).nyquistScale()
1818-
if iimult is not None:
1819-
iiscale /= iimult
1820-
# Create the effective bandpass.
1821-
wave_list = np.union1d(wave_list, bandpass.wave_list)
1822-
wave_list = wave_list[wave_list >= bandpass.blue_limit]
1823-
wave_list = wave_list[wave_list <= bandpass.red_limit]
1824-
effective_bandpass = galsim.Bandpass(
1825-
galsim.LookupTable(wave_list, bandpass(wave_list) * SED(wave_list),
1826-
interpolant='linear'))
1827-
# If there's only one inseparable profile, let it draw itself.
18281892
wmult = kwargs.get('wmult', 1)
1829-
if len(insep_profs) == 1:
1830-
effective_prof_image = insep_profs[0].drawImage(
1831-
effective_bandpass, wmult=wmult, scale=iiscale, integrator=integrator,
1832-
method='no_pixel')
1833-
# Otherwise, use superclass ChromaticObject to draw convolution of inseparable profiles.
1834-
else:
1835-
effective_prof_image = ChromaticObject.drawImage(
1836-
insep_obj, effective_bandpass, wmult=wmult, scale=iiscale,
1837-
integrator=integrator, method='no_pixel')
1838-
1839-
# Image -> InterpolatedImage
1840-
# It could be useful to cache this result if drawing more than one object with the same
1841-
# PSF+SED combination. This naturally happens in a ring test or when fitting the
1842-
# parameters of a galaxy profile to an image when the PSF is constant.
1843-
effective_prof = galsim.InterpolatedImage(effective_prof_image, gsparams=self.gsparams)
1893+
# Collapse inseparable profiles into one effective profile
1894+
effective_prof = ChromaticConvolution._effective_prof_cache(
1895+
tuple(sep_SED), tuple(insep_profs), bandpass, iimult, tuple(wave_list),
1896+
wmult, integrator, self.gsparams)
1897+
18441898
# append effective profile to separable profiles (which should all be GSObjects)
18451899
sep_profs.append(effective_prof)
18461900
# finally, convolve and draw.
18471901
final_prof = galsim.Convolve(sep_profs, gsparams=self.gsparams)
1848-
return final_prof.drawImage(image=image,**kwargs)
1902+
return final_prof.drawImage(image=image, **kwargs)
1903+
1904+
ChromaticConvolution._effective_prof_cache = galsim.utilities.LRU_Cache(
1905+
ChromaticConvolution._get_effective_prof, maxsize=10)
18491906

18501907

18511908
class ChromaticDeconvolution(ChromaticObject):

galsim/utilities.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,100 @@ def _gammafn(x):
417417
0.00000000000000122678, -0.00000000000000011813, 0.00000000000000000119,
418418
0.00000000000000000141, -0.00000000000000000023, 0.00000000000000000002
419419
)
420+
421+
422+
class LRU_Cache:
423+
""" Simplified Least Recently Used Cache.
424+
Mostly stolen from http://code.activestate.com/recipes/577970-simplified-lru-cache/,
425+
but added a method for dynamic resizing. The least recently used cached item is
426+
overwritten on a cache miss.
427+
428+
@param user_function A python function to cache.
429+
@param maxsize Maximum number of inputs to cache. [Default: 1024]
430+
431+
Usage
432+
-----
433+
>>> def slow_function(*args) # A slow-to-evaluate python function
434+
>>> ...
435+
>>>
436+
>>> v1 = slow_function(*k1) # Calling function is slow
437+
>>> v1 = slow_function(*k1) # Calling again with same args is still slow
438+
>>> cache = galsim.utilities.LRU_Cache(slow_function)
439+
>>> v1 = cache(*k1) # Returns slow_function(*k1), slowly the first time
440+
>>> v1 = cache(*k1) # Returns slow_function(*k1) again, but fast this time.
441+
442+
Methods
443+
-------
444+
>>> cache.resize(maxsize) # Resize the cache, either upwards or downwards. Upwards resizing
445+
# is non-destructive. Downwards resizing will remove the least
446+
# recently used items first.
447+
"""
448+
def __init__(self, user_function, maxsize=1024):
449+
# Link layout: [PREV, NEXT, KEY, RESULT]
450+
self.root = root = [None, None, None, None]
451+
self.user_function = user_function
452+
self.cache = cache = {}
453+
454+
last = root
455+
for i in range(maxsize):
456+
key = object()
457+
cache[key] = last[1] = last = [last, root, key, None]
458+
root[0] = last
459+
460+
def __call__(self, *key):
461+
cache = self.cache
462+
root = self.root
463+
link = cache.get(key)
464+
if link is not None:
465+
# Cache hit: move link to last position
466+
link_prev, link_next, _, result = link
467+
link_prev[1] = link_next
468+
link_next[0] = link_prev
469+
last = root[0]
470+
last[1] = root[0] = link
471+
link[0] = last
472+
link[1] = root
473+
return result
474+
# Cache miss: evaluate and insert new key/value at root, then increment root
475+
# so that just-evaluated value is in last position.
476+
result = self.user_function(*key)
477+
root[2] = key
478+
root[3] = result
479+
oldroot = root
480+
root = self.root = root[1]
481+
root[2], oldkey = None, root[2]
482+
root[3], oldvalue = None, root[3]
483+
del cache[oldkey]
484+
cache[key] = oldroot
485+
return result
486+
487+
def resize(self, maxsize):
488+
""" Resize the cache. Increasing the size of the cache is non-destructive, i.e.,
489+
previously cached inputs remain in the cache. Decreasing the size of the cache will
490+
necessarily remove items from the cache if the cache is already filled. Items are removed
491+
in least recently used order.
492+
493+
@param maxsize The new maximum number of inputs to cache.
494+
"""
495+
oldsize = len(self.cache)
496+
if maxsize == oldsize:
497+
return
498+
else:
499+
root = self.root
500+
cache = self.cache
501+
if maxsize < oldsize:
502+
for i in range(oldsize - maxsize):
503+
# Delete root.next
504+
current_next_link = root[1]
505+
new_next_link = root[1] = root[1][1]
506+
new_next_link[0] = root
507+
del cache[current_next_link[2]]
508+
elif maxsize > oldsize:
509+
for i in range(maxsize - oldsize):
510+
# Insert between root and root.next
511+
key = object()
512+
cache[key] = link = [root, root[1], key, None]
513+
root[1][0] = link
514+
root[1] = link
515+
else:
516+
raise ValueError("Invalid maxsize: {0:}".format(maxsize))

tests/test_chromatic.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,9 @@ def evaluateAtWavelength(self, wave):
13131313
ret = ret.shear(g1 = this_shear)
13141314
return ret
13151315

1316+
def __repr__(self):
1317+
return 'galsim.ChromaticGaussian(%r)'%self.sigma
1318+
13161319
# For this test, we're going to use the ChromaticGaussian defined above. This class is simple
13171320
# enough that evaluation of both the interpolated and exact versions is very fast, so it won't
13181321
# slow down the tests too much to do both ways. Note that for initial tests (fair comparison

0 commit comments

Comments
 (0)