Repository navigation
Vectorize string output for Time formats - #19942
Conversation
|
Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.
|
|
Not sure why CI is skipped. There is no skip ci in commit and I cannot find a button to run them. |
|
Thanks @pllim! Not sure why they didn't fire at first either — merging main in to update the branch seems to have kicked the workflows off, and they're running now. |
|
For future reference, we prefer a rebase to avoid merge commit in a PR commit history. Note to maintainer: Please squash to merge when it is time, if you choose to accept the PR. Thanks, all. |
taldcroft
left a comment
There was a problem hiding this comment.
@veyron-kairo - looks good in my first quick look. This is an important performance improvement that should be highlighted in the What's New. This does not need to be too long, probably fairly similar to the change log.
I'll need a bit more time to dig into the main code, hopefully by early next week.
| t.yday == np.array(["2000:336:00:00:00.000", "2001:335:01:01:01.123"]) | ||
| ) | ||
|
|
||
| @pytest.mark.parametrize("fmt", ["iso", "isot", "yday", "fits"]) |
There was a problem hiding this comment.
Should we test datetime64 as well? It just goes through isot so maybe it is not independent?
There was a problem hiding this comment.
you're right, it isn't really independent. datetime64 just runs the isot template at precision=9 and then does .astype("datetime64"), so the _value_fast part is already exercised by the isot/precision=9 case here, and the astype step runs on strings we've shown are byte-identical between the two paths. The end-to-end round-trip is also already covered by test_datetime64. Happy to add it to the parametrization anyway if you'd prefer the explicit coverage, but I left it out to avoid redundant cases.
|
@taldcroft quick process question — the branch has picked up a couple of "Merge main" commits from keeping it in sync, which I know pllim would rather we avoid. I can clean those out with a rebase, but that's a force-push and it'd scramble your "changes since last review" view while you're still going through it. Would you rather I rebase to drop the merge commits now, or leave the history as-is and do a single tidy-up rebase right before merge (or just let the squash-merge handle it)? Happy either way — just didn't want to disrupt your review. |
|
@veyron-kairo - sorry for the delay, I'm starting to look at this again. Yes, please rebase. That's the way we do things in astropy and I'm used to dealing with that. For the most part you don't rebase unless there is an actual merge conflict. |
5609d90 to
121adde
Compare
|
Thanks @taldcroft, and no worries about the delay! Good to know — I'll keep that in mind for next time and only rebase when there's an actual conflict. Gone ahead and rebased this one onto main to drop the merge commits, so the history is linear now. 👍 |
|
@veyron-kairo - it appears that |
| t.yday == np.array(["2000:336:00:00:00.000", "2001:335:01:01:01.123"]) | ||
| ) | ||
|
|
||
| @pytest.mark.parametrize("fmt", ["iso", "isot", "yday", "fits"]) |
| if pos < len(str_fmt): | ||
| literal = str_fmt[pos:] | ||
| pieces.append((literal, None, None)) | ||
| width += len(literal) |
There was a problem hiding this comment.
This new code is not covered in any test. You would need to use a format that has a trailing literal.
There was a problem hiding this comment.
Added test_string_value_trailing_literal, which formats with a trailing literal so this branch is now exercised.
|
@veyron-kairo - this is great work, thanks! Just needs a rebase and a couple new tests. I took the liberty of expanding the doc strings since it is so easy now with AI. They look about right but you should double check for accuracy. |
The .value property formatted each time with str.format in a Python loop, which dominated the runtime (the ERFA call doing the real work is a tiny fraction of it). Build the output with array operations instead: write the digits of each component into a single code-point buffer and view the rows as fixed-width strings. This is well over ten times faster for large arrays. The output stays byte-for-byte identical. Layouts that aren't a fixed-width run of integer fields fall back to the old per-element path, e.g. years that span different numbers of digits within a single array.
Compare the fast output to the original loop (disabled via monkeypatch) over several formats, precisions and a masked value, and check that mixed-width years fall back correctly.
_write_decimal always zero-pads, so a fixed-width spec without the "0" flag (which str.format pads with spaces) would come out wrong. Restrict the vectorized path to zero-padded or bare integer fields and leave anything else to the per-element loop. No built-in subformat is affected; this just guards custom format strings.
towncrier checks that fragment numbers correspond to the PR, so this is 19942 rather than the issue number.
Use the phrasing suggested in review.
Cover the instant just after a leap day, and a few non-default out_subfmt values including ones that drop the seconds field.
061cad0 to
158579c
Compare
|
Thanks @taldcroft! Rebased onto the latest main with the merge commit removed, and I kept your AI-docs commit in place. I went through the expanded docstrings against the implementation and they all check out. Tests pass locally. Will add the extra test cases you flagged next. |
|
@veyron-kairo - it would be good if you could verify that the new PR is indeed equally fast to what you had. @taldcroft - with my changes, it would be good to have you do a quick sanity check... A larger question (not for this PR), is whether we should start moving to numpy's new string dtype "T". It uses UTF-8 so uses less space, though the minimum is 16 bytes (everything up to 15 characters get stored directly; 16 and more is stored in a separate "arena"). Still, for |
e68e9f8 to
3588c7a
Compare
|
Annoyingly, the code failed on oldestdeps, because numpy 2.0 had a bug for |
3588c7a to
43957e1
Compare
|
Thanks @mhvk — and no need to apologise at all, the rewrite is much cleaner and I learned a lot from it (I didn't know about the On speed: I tried to verify as you asked. On numpy 2.4.6 I'm fairly consistently seeing the new version come out a few times slower than the old buffer path for the common uniform-width case — e.g. for a 1M-element That said, I don't think it's worth chasing: the old buffer only hit that speed when every year had the same width — it fell back to the per-element path for mixed widths and, as you found, truncated large Script I used, in case it's handy or doesn't match your numbers: benchmark scriptimport time
import numpy as np
import erfa
from astropy.time import Time
from astropy.time.formats import (
TimeString, _day_of_year, _SUBFMT_FIELD, _INT_SPEC,
)
# Original buffer implementation, for comparison.
def _orig_write_decimal(out, values, width, signed):
d = np.asarray(values, dtype=np.int64)
if signed:
neg = d < 0
d = np.abs(d)
for i in range(width - 1, -1, -1):
out[..., i] = d % 10 + ord("0")
d //= 10
if signed:
out[..., 0] = np.where(neg, ord("-"), ord("+"))
def _orig_field_width(spec, values):
m = _INT_SPEC.fullmatch(spec)
if m is None:
return None, None
sign, zero, digits = m.group(1, 2, 3)
if digits:
return (int(digits), sign == "+") if zero else (None, None)
if values.size == 0:
return 1, False
low = int(values.min())
if sign or low < 0 or len(str(low)) != len(str(int(values.max()))):
return None, None
return len(str(int(values.max()))), False
def _orig_value_fast(self, str_fmt):
scale = (self.scale.upper().encode("ascii"),)
iys, ims, ids, ih = erfa.d2dtf(scale, self.precision, self.jd1, self.jd2)
f = {"year": np.asarray(iys), "mon": np.asarray(ims), "day": np.asarray(ids),
"hour": np.asarray(ih["h"]), "min": np.asarray(ih["m"]),
"sec": np.asarray(ih["s"]), "fracsec": np.asarray(ih["f"])}
if "{yday:" in str_fmt:
f["yday"] = _day_of_year(f["year"], f["mon"], f["day"])
pieces, width, pos = [], 0, 0
for mt in _SUBFMT_FIELD.finditer(str_fmt):
if mt.start() > pos:
lit = str_fmt[pos:mt.start()]; pieces.append((lit, None, None)); width += len(lit)
name, spec = mt.group(1, 2)
if name not in f:
return None
fw, sg = _orig_field_width(spec, f[name])
if fw is None:
return None
pieces.append((f[name], fw, sg)); width += fw; pos = mt.end()
if pos < len(str_fmt):
lit = str_fmt[pos:]; pieces.append((lit, None, None)); width += len(lit)
buf = np.empty(f["year"].shape + (width,), dtype=np.uint32); col = 0
for v, fw, sg in pieces:
if fw is None:
buf[..., col:col + len(v)] = [ord(c) for c in v]; col += len(v)
else:
_orig_write_decimal(buf[..., col:col + fw], v, fw, sg); col += fw
return buf.view(f"U{width}").reshape(self.jd1.shape)
cur = TimeString._value_fast
def bench(impl, N, reps=5):
TimeString._value_fast = impl
best = float("inf")
for _ in range(reps):
t = Time("2001-01-01T00:00:00", format="isot") + np.arange(N) * (1.0 / 86400)
t0 = time.perf_counter()
_ = t.fits # first access; .fits is cached afterwards
best = min(best, time.perf_counter() - t0)
return best
for N in [100_000, 1_000_000]:
o = bench(_orig_value_fast, N)
m = bench(cur, N)
print(f"N={N:>9,} orig={o:.4f} s current={m:.4f} s ({m / o:.1f}x)")
TimeString._value_fast = cur |
|
@mhvk - I had a quick look at your commit. At this point I share your view that the tests are good so I'm not going to spend too much time confirming that your new code is correct. (I already spent a lot of time on the initial version!). But I will ask that you revert (or more strongly justify) removing the unit tests on private functions. I disagree with your stated philosophy of not testing those. On the contrary, I often make a point of factoring out smaller (private) functions with the express intent that they can be more easily and transparently tested. That's really the case here, and I was actually quite happy with all those unit tests that confirmed at a low-level that those private functions were working as expected. |
Good point @veyron-kairo. (Your comment came in while I was typing mine). I'm hesitant to accept a factor of 5 slowdown for the new code even if it is cleaner. I actually understood your original implementation and it seemed sensible, a bit more like what one would do in C with pre-allocating and filling in digits. So maybe we do need a real discussion about strategy here. |
|
Thanks @taldcroft — really appreciate you taking the time to dig into this. I'm totally happy to follow whatever you and @mhvk settle on for the strategy. |
|
@veyron-kairo - Ai, that's a horrible slowdown! I had only done the simple test from #19929, and it seemed to be just as fast as your implementation, so thought it was OK. But it sounds like I did not look at the numbers carefully enough. Given the slowdown, I think it is obvious we should not use my commits. One possibility is to just remove them again. Alternatively, my earlier attempt I am sure was similar in speed to yours - I now pushed this to #19998. But like your original, that will fail the test I added. Aside: we really should add some new items to the benchmarks. p.s. @taldcroft - the private function tests I removed were all tests of a function that no longer exists in this PR. My comment reflects mostly that if you test private functions rather than API, then anyone who refactors and removes/changes private stuff will get failing tests and decide they don't matter. To me, that is a reason to avoid testing them if possible, but of course in some of the tests here it is really best to actually check the code even runs! |
|
Thanks @mhvk — and thanks for pushing the alternative up so there's concrete code to compare against. I ran the same benchmark on #19998 too, just to be thorough (checked out the branch and timed the real These are of course just my numbers on one machine, so they may well not match what you're seeing — happy to share the exact script if it's useful. And either way I'm glad to go whichever direction you and @taldcroft prefer. If it'd help, I'm happy to take a pass at keeping the fast pre-allocated approach but fixing the large-year overflow so it passes the test you added (and restoring the low-level tests). |
|
@veyron-kairo - thanks for your testing. In the end, it turns out I was simply an idiot, having forgotten about how I suggest you simply remove my additional commits, only keeping the new test, and force-push so you are back at what you had. Of course, you'd also need some type of guard that ensures the new test also passes (perhaps just falling back to the slow path, since this is hardly a common case!). Apologies for leading us all the wrong direction! p.s. I am actually somewhat surprised that |
|
p.s. Do add a comment to your custom int-to-decimal converted to note that it is faster than |
|
FWIW, |
A fixed integer width such as the FITS ``{year:+06d}`` field silently
dropped digits for values that did not fit (e.g. a year past 9999). Detect
that in `_field_width` and fall back to the per-element path, which formats
such rare cases correctly.
Co-authored-by: Marten van Kerkwijk <[email protected]>
Writing digits with integer arithmetic is several times faster than NumPy's string casts, which format each element via a Python string conversion. Add a comment so it is not later "simplified" into a slower `astype`.
43957e1 to
7aa2396
Compare
|
Thanks @mhvk — and no worries at all, it was a useful detour and I learned a lot from it! I've gone ahead and done as you suggested: removed your two commits so it's back to the buffer version, and added a small guard in |
mhvk
left a comment
There was a problem hiding this comment.
I found one more thing that perhaps increases the performance even more, but see whether you agree. In the meantime, I'll approve. Thanks, @veyron-kairo!
| negative = digits < 0 | ||
| digits = np.abs(digits) | ||
| for i in range(width - 1, -1, -1): | ||
| out[..., i] = digits % 10 + ord("0") |
There was a problem hiding this comment.
I think you can do slightly better here still with
digits, digit = np.divmod(digits, 10)
out[..., i] = digit + ord("0")
This because it saves one division by 10, which is relatively costly.
Actually, you may want to check yourself, but my trials find that even faster is
by10 = digits // 10
out[..., i] = (digits - 10 * by10) + ord("0")
digits = by10
I think this is because it vectorizes better.
Aside on vectorization: I tried a bit improving astype("T") in numpy, but it turns out it just doesn't work, because for conversion to string you have to work per element, so you cannot vectorize anything.
There was a problem hiding this comment.
Done — applied the subtract-multiply version in 87e3cde. I timed all three end-to-end (current, np.divmod, and yours) and yours was the fastest on the full .isot/.fits path, so that's the one that went in. Thanks!
Reuse the floor division to compute the remainder (`digits - 10 * by10`) instead of a separate `% 10`, which is measurably faster end-to-end. Co-authored-by: Marten van Kerkwijk <[email protected]>
|
Thanks @mhvk — and you were right, the subtract-multiply is the fastest of the options. I checked all three end-to-end on numpy 2.4.6 (current |
|
Excellent, happy that I added at least a little! Since @taldcroft approved already, I'll merge. Thanks again! |
|
@mhvk @taldcroft , thanks a lot again got to learn a lot from you 🙏 ! |
Fixes #19929.
TimeString.valueformatted each element withstr.formatin a Python loop, which dominated the runtime — for 100k times, ERFA'sd2dtftakes ~4 ms while the loop took ~145 ms.This builds the strings with array operations: each date/time component's digits are written into a single code-point buffer which is then viewed as fixed-width strings. On the 100k example this drops
.isotfrom ~150 ms to ~9 ms (~16x), and similarly foriso,yday,fitsanddatetime64.Output is byte-for-byte identical. Layouts that aren't a fixed-width run of integer fields (a custom
format_string, or years spanning different digit counts within one array) fall back to the original per-element path, so behaviour is unchanged there.As discussed on the issue, this is a pure-numpy approach (no new C).