Skip to content

Latest commit

 

History

History
1086 lines (929 loc) · 50.9 KB

notes.org

File metadata and controls

1086 lines (929 loc) · 50.9 KB

ts.el notes

Plans

  • Timestamp library, making it easy to work with timestamps and dates in Emacs.
  • Using defstructs
    • Define accessors with macros
      • Accessors store precomputed data for later access (e.g. if day-of-week is not yet computed, store it in the struct and return it)
  • Store timestamps as either Emacs internal time values or as Unix timestamps
    • Need to benchmark which is faster to work with, format, decode, increment, etc. Unix timestamps would be simpler to deal with, but maybe slower…?
  • Accessors should be named unambiguously
    • e.g. dow for day-of-week, dom for day-of-month, rather than day, which could be either
      • And maybe dow-num or dow-name to be even clearer

Tasks

[#A] Export Info manual from readme

[#A] Mention hash tables

;; NOTE: ts structs don't (sometimes? or always?) compare properly
;; with default hash tables, e.g. this code:

;; (let* ((ts-a #s(ts nil nil nil nil nil nil nil nil nil nil nil nil nil nil nil nil 1572670800.0))
;; (ts-b #s(ts nil nil nil nil nil nil nil nil nil nil nil nil nil nil nil nil 1572584400.0)))
;; (list :equal (equal ts-a ts-b)
;; :sxhash-equal (equal (sxhash ts-a) (sxhash ts-b)))) ;;=> (:equal nil :sxhash-equal t)

;; So we must use the "contents-hash" table as described in the Elisp manual.
(define-hash-table-test 'contents-hash 'equal 'sxhash-equal)

Use define-inline instead of defsubst

Ensure timezones are handled properly

Unix timestamps, by definition, are in UTC. We need to ensure that timezones are handled properly when creating timestamps, so that e.g. a timestamp in a non-UTC timezone is converted to UTC when calling ts-parse.

Address feedback from Reddit post

Chris Wellons gave a lot of good feedback.

Emacs 27.1 changes

From the NEWS file:

** Time values

+++
*** New function time 'time-convert' converts Lisp time values
to Lisp timestamps of various forms, including a new timestamp form
(TICKS . HZ) where TICKS is an integer and HZ a positive integer
denoting a clock frequency.

+++
*** Although the default timestamp format is still '(HI LO US PS)',
it is planned to change in a future Emacs version, to exploit bignums.
The documentation has been updated to mention that the timestamp
format may change and that programs should use functions like
'format-time-string', 'decode-time', and 'time-convert' rather than
probing the innards of a timestamp directly, or creating a timestamp
by hand.

+++
*** 'encode-time' supports a new API '(encode-time TIME)'.
The old 'encode-time' API is still supported.

+++
*** A new package to parse ISO 8601 time, date, durations and
intervals has been added.  The main function to use is
'iso8601-parse', but there's also 'iso8601-parse-date',
'iso8601-parse-time', 'iso8601-parse-duration' and
'iso8601-parse-interval'.  All these functions return decoded time
structures, except the final one, which returns three of them (start,
end and duration).

+++
*** 'time-add', 'time-subtract', and 'time-less-p' now accept
infinities and NaNs too, and propagate them or return nil like
floating-point operators do.

+++
*** New function 'time-equal-p' compares time values for equality.

+++
*** 'format-time-string' supports a new conversion specifier flag '+'
that acts like the '0' flag but also puts a '+' before nonnegative
years containing more than four digits.  This is for compatibility
with POSIX.1-2017.

+++
*** To access (or alter) the elements a decoded time value, the
'decoded-time-second', 'decoded-time-minute', 'decoded-time-hour',
'decoded-time-day', 'decoded-time-month', 'decoded-time-year',
'decoded-time-weekday', 'decoded-time-dst' and 'decoded-time-zone'
accessors can be used.

*** The new functions 'date-days-in-month' (which will say how many
days there are in a month in a specific year), 'date-ordinal-to-time'
(that computes the date of an ordinal day), 'decoded-time-add' for
doing computations on a decoded time structure), 'make-decoded-time'
(for making a decoded time structure with only the given keywords
filled out), and 'encoded-time-set-defaults' (which fills in nil
elements as if it's midnight January 1st, 1970) have been added.

Some of those may be useful.

Address feedback from Paul Eggert

He was kind enough to post some feedback on the emacs-devel list:

I looked briefly at it, and don’t see any compatibility issues - not that I understand all the code, which depends on packages I don’t use.

The code’s comments say that format-time-string is too slow. What performance issues did you run into? At any rate I think you’ll find that this:

(string-to-number (format-time-string “%Y” (ts-unix struct)))

is more efficient written this way:

(nth 5 (decode-time (ts-unix struct)))

and I expect you can speed up the code further by caching the entire result of decode-time instead of calling format-time-string for each component.

Also, the timestamp functions in Emacs 27 should simplify ts.el, once you can assume Emacs 27. For example, in Emacs 27 you can do something like this:

(decoded-time-add X (make-decoded-time :year 10))

to add 10 years to a broken-down timestamp X.

One more thing: ts.el’s extensive use of float-time is fine for calendrical applications but has limited resolution (2**-22 s or about 2e-7 s for today’s timestamps) and so would be problematic for apps requiring higher-resolution timestamps.

Ideas

Inc/dec until

Something like this, but easier to use:

(cl-loop with ts = (ts-now)
         while (not (= (ts-dow ts) 6))
         do (ts-decf (ts-day ts))
         finally return (ts-format ts))
;;=> "2019-07-27 18:15:12 -0500"

(cl-loop with ts = (ts-dec 'day 1 (ts-now))
         while (not (= (ts-dow ts) 0))
         do (ts-decf (ts-day ts))
         finally return (ts-format ts))
;;=> "2019-07-21 18:15:17 -0500"

Tools

Reset all struct accessors

(cl-loop for (name . opts) in (cl-struct-slot-info 'ts)
         for accessor = (intern (concat "ts-" (symbol-name name)))
         for aliases = (--map (intern (concat "ts-" (symbol-name it)))
                              (plist-get (cdr opts) :aliases))
         for cmacro = (intern (concat "ts-" (symbol-name name) "--cmacro")) 
         do (unintern accessor)
         do (--each aliases
              (unintern it))
         do (unintern cmacro))

Benchmarking

Emacs internal time values vs. Unix timestamps

(cl-defstruct ts
  hour minute second
  dom dow doe
  moy
  year
  tz
  internal unix)

Formatting

(let ((format "%Y-%m-%d %H:%M:%S"))
  (bench-multi :times 100000
    :forms (("Unix timestamp" (format-time-string format 1544311232))
            ("Internal time" (format-time-string format '(23564 20962 864324 108000))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
Internal time1.004.84653150551.1269977660000006
Unix timestampslowest4.85182270799999951.1267304740000004

No difference when formatting.

Getting current time

(bench-multi :times 100000
  :forms (("Unix timestamp" (float-time))
          ("Internal time" (current-time))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
Unix timestamp1.120.00858470599999999800.0
Internal timeslowest0.00958325800.0

Getting the current time as a Unix timestamp is slightly faster. The docs for float-time warn that it’s floating point and that current-time should be used if precision is needed. I don’t think that’s important for us.

org-fix-decoded-time vs. ts- setters

With filling

(let* ((s "mon 9 dec 2018")
       (parsed (parse-time-string s)))
  (bench-multi :times 1000
    :ensure-equal t
    :forms (("org-fix-decoded-time" (ts-fill (make-ts :unix (float-time (apply #'encode-time (org-fix-decoded-time parsed))))))
            ("cl-loop nth" (ts-fill (make-ts :unix (float-time (apply #'encode-time (cl-loop for i from 0 to 5
                                                                                             when (null (nth i parsed))
                                                                                             do (setf (nth i parsed) 0)
                                                                                             finally return parsed))))))
            ("cl-loop elt" (ts-fill (make-ts :unix (float-time (apply #'encode-time (cl-loop for i from 0 to 5
                                                                                             when (null (elt parsed i))
                                                                                             do (setf (elt parsed i) 0)
                                                                                             finally return parsed))))))
            ("ts- accessors" (-let* (((S M H d m Y) parsed))
                               (ts-fill (ts-update (make-ts :second (or S 0) :minute (or M 0) :hour (or H 0)
                                                            :dom (or d 0) :moy (or m 0) :year (or Y 0))))))
            )))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts- accessors2.110.681440631000000100.0
org-fix-decoded-time1.001.4378614710.40317458900000247
cl-loop nth1.011.442054349000000210.40715375199999926
cl-loop eltslowest1.452211832000000110.41347589399998697

Just returning unix time

(let* ((s "mon 9 dec 2018"))
  (bench-multi :times 1000
    :ensure-equal t
    :forms (("org-fix-decoded-time" (ts-unix (make-ts :unix (float-time (apply #'encode-time (org-fix-decoded-time (parse-time-string s)))))))
            ("cl-loop nth" (ts-unix (make-ts :unix (float-time (apply #'encode-time (cl-loop with parsed = (parse-time-string s)
                                                                                             for i from 0 to 5
                                                                                             when (null (nth i parsed))
                                                                                             do (setf (nth i parsed) 0)
                                                                                             finally return parsed))))))
            ("cl-loop elt" (ts-unix (make-ts :unix (float-time (apply #'encode-time (cl-loop with parsed = (parse-time-string s)
                                                                                             for i from 0 to 5
                                                                                             when (null (elt parsed i))
                                                                                             do (setf (elt parsed i) 0)
                                                                                             finally return parsed))))))
            ("ts- accessors" (-let* ((parsed (parse-time-string s))
                                     ((S M H d m Y) parsed))
                               (ts-unix (ts-update (make-ts :second (or S 0) :minute (or M 0) :hour (or H 0)
                                                            :dom (or d 0) :moy (or m 0) :year (or Y 0))))))
            ("ts-parse" (ts-unix (ts-parse s)))
            ("ts-parse-defsubst" (ts-unix (ts-parse-defsubst s)))
            ("ts-parse-macro" (ts-unix (ts-parse-macro s))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts-parse-macro1.000.02863431600.0
ts-parse-defsubst1.010.0286917100.0
cl-loop nth1.000.02910304600.0
cl-loop elt1.040.02924638500.0
org-fix-decoded-time1.000.03046353500.0
ts- accessors1.090.03052740800.0
ts-parseslowest0.03340808400.0

Funcall overhead is noticeable. We could provide the macro or defsubst in addition to the function, so users in tight loops could avoid funcall overhead.

As ts-parse

(let* ((s "mon 9 dec 2018"))
  (bench-multi :times 1000
    :forms (("ts-parse" (ts-parse s))
            ("ts-parse ts-unix" (ts-unix (ts-parse s))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts-parse1.020.03156136900.0
ts-parse ts-unixslowest0.03219344200.0

Accessor dispatch vs. (string-to-number (format-time-string...

(let* ((ts (ts-now))
       (unix (ts-unix ts)))
  (ts-fill ts)
  (bench-multi :times 1000
    :ensure-equal t
    :forms (("Accessor dispatch" (ts-year ts))
            ("(string-to-number (format-time-string..." (string-to-number (format-time-string "%Y" unix))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
Accessor dispatch93.170.00051462700.0
(string-to-number (format-time-string…slowest0.04794990700.0

Filling all fields at once with (split-string (format-time-string...

(let ((a (ts-now))
      (b (ts-now)))
  (bench-multi :times 1000
    :ensure-equal t
    :forms (("Filling just year" (ts-year a))
            ("Filling all fields" (ts-year (cl-loop with vals = (split-string (format-time-string "%H\f%M\f%S\f%d\f%m\f%Y\f%w\f%a\f%A\f%j\f%V\f%b\f%B\f%Z\f%z" (ts-unix b)) "\f")
                                                    for f in '(:hour :minute :second
                                                                     :dom :moy :year
                                                                     :dow :day :day-full
                                                                     :doy :woy
                                                                     :mon :month
                                                                     :tz-abbr :tz-offset)
                                                    for i from 0
                                                    for val = (nth i vals)
                                                    for val = (or (ignore-errors (string-to-number val))
                                                                  val)
                                                    append (list f val) into data
                                                    finally return (apply #' make-ts data)))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
Filling just year111.270.000575391999999999900.0
Filling all fieldsslowest0.0640251130000000100.0
(let ((a (ts-now))
      (b (ts-now))
      (c (ts-now)))
  (bench-multi :times 1000
    :ensure-equal t
    :forms (("Filling just year" (ts-year a))
            ("Filling all fields with ts-fill" (ts-year (ts-fill b)))
            ("Filling all fields" (ts-year (cl-loop with vals = (split-string (format-time-string "%H\f%M\f%S\f%d\f%m\f%Y\f%w\f%a\f%A\f%j\f%V\f%b\f%B\f%Z\f%z" (ts-unix c)) "\f")
                                                    for f in '(:hour :minute :second
                                                                     :dom :moy :year
                                                                     :dow :day :day-full
                                                                     :doy :woy
                                                                     :mon :month
                                                                     :tz-abbr :tz-offset)
                                                    for i from 0
                                                    for val = (nth i vals)
                                                    for val = (or (ignore-errors (string-to-number val))
                                                                  val)
                                                    append (list f val) into data
                                                    finally return (apply #' make-ts data)))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
Filling just year26.190.00057838300.0
Filling all fields with ts-fill4.260.01514709600.0
Filling all fieldsslowest0.0645318729999999900.0
(let ((unix (ts-unix (ts-now))))
  (bench-multi :times 1000
    :ensure-equal t
    :forms (("format-time-string for each field"
             (cl-loop for c in '("%H" "%M" "%S" "%d" "%m" "%Y" "%w" "%a" "%A" "%j" "%V" "%b" "%B" "%Z" "%z")
                      collect (format-time-string c unix)))
            ("format-time-string once" (split-string (format-time-string "%H\f%M\f%S\f%d\f%m\f%Y\f%w\f%a\f%A\f%j\f%V\f%b\f%B\f%Z\f%z" unix) "\f")))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
format-time-string once8.720.03560571499999999600.0
format-time-string for each fieldslowest0.3105577379999999700.0
(let* ((unix (ts-unix (ts-now)))
       (constructors '("%H" "%M" "%S" "%d" "%m" "%Y" "%w" "%a" "%A" "%j" "%V" "%b" "%B" "%Z" "%z"))
       (results (cl-loop for i from 0 to (length constructors)
                         collect (progn
                                   (garbage-collect)
                                   (let* ((fields (-slice constructors 0 i))
                                          (multi-string (s-join "\f" fields))
                                          (multi-calls (car (benchmark-run-compiled 1000
                                                              (cl-loop for field in fields
                                                                       collect (format-time-string field unix)))))
                                          (multi-field (car (benchmark-run-compiled 1000
                                                              (split-string (format-time-string multi-string unix)))))
                                          (difference (format "%.04f" (- multi-field multi-calls ))))
                                     (list (1+ i)
                                           (format "%.04f" multi-calls)
                                           (format "%.04f" multi-field)
                                           difference
                                           (format "%.04f" (/ multi-calls
                                                       multi-field)))))))
       (table (list '("Fields" "Multiple calls" "One call" "Difference" "x faster")
                    'hline)))
  (append table results))
FieldsMultiple callsOne callDifferencex faster
10.00010.02150.02140.0043
20.02170.02310.00140.9385
30.04280.0249-0.01801.7223
40.06390.0256-0.03842.5004
50.08480.0264-0.05853.2179
60.10590.0271-0.07883.9039
70.12690.0282-0.09884.5074
80.14790.0290-0.11895.1008
90.16930.0301-0.13925.6169
100.19040.0310-0.15946.1446
110.21130.0318-0.17956.6403
120.23260.0329-0.19977.0796
130.25370.0338-0.21997.5002
140.27490.0349-0.24007.8714
150.29580.0357-0.26018.2849
160.31690.0368-0.28028.6213

Old ts-fill vs new ts-fill

Including struct and macro/function definitions because the code may change in the future.

NOTE: Something weird happens when evaluating these macro-defining, function-defining blocks in Org. After running them, the functions aren’t even defined in Emacs. I don’t understand how that’s possible. So some of the results are…weird. Anyway, when I manually eval the macros and functions outside of the source block, and then run the benchmark part only, the results show that the “new” and defun-based functions are much faster.

This code just changes the number of times format-time-string is called:

(unintern 'ts-fill)
(unintern 'ts-fill2)

(ts-defstruct ts
  (hour nil
        :accessor-init (string-to-number (format-time-string "%H" (ts-unix struct)))
        :aliases (H)
        :constructor "%H"
        :type integer)
  (minute nil
          :accessor-init (string-to-number (format-time-string "%M" (ts-unix struct)))
          :aliases (min M)
          :constructor "%M"
          :type integer)
  (second nil
          :accessor-init (string-to-number (format-time-string "%S" (ts-unix struct)))
          :aliases (sec S)
          :constructor "%S"
          :type integer)
  (dom nil
       :accessor-init (string-to-number (format-time-string "%d" (ts-unix struct)))
       :aliases (d)
       :constructor "%d"
       :type integer)
  (moy nil
       :accessor-init (string-to-number (format-time-string "%m" (ts-unix struct)))
       :aliases (m month-of-year)
       :constructor "%m"
       :type integer)
  (year nil
        :accessor-init (string-to-number (format-time-string "%Y" (ts-unix struct)))
        :aliases (Y)
        :constructor "%Y"
        :type integer)

  (dow nil
       :accessor-init (string-to-number (format-time-string "%w" (ts-unix struct)))
       :aliases (day-of-week)
       :constructor "%w"
       :type integer)
  (day nil
       :accessor-init (format-time-string "%a" (ts-unix struct))
       :aliases (day-abbr)
       :constructor "%a")
  (day-full nil
            :accessor-init (format-time-string "%A" (ts-unix struct))
            :aliases (day-name)
            :constructor "%A")
  ;; (doe nil
  ;;      :accessor-init (days-between (format-time-string "%Y-%m-%d 00:00:00" (ts-unix struct))
  ;;                                   "1970-01-01 00:00:00")
  ;;      :aliases (day-of-epoch))
  (doy nil
       :accessor-init (string-to-number (format-time-string "%j" (ts-unix struct)))
       :aliases (day-of-year)
       :constructor "%j"
       :type integer)

  (woy nil
       :accessor-init (string-to-number (format-time-string "%V" (ts-unix struct)))
       :aliases (week week-of-year)
       :constructor "%V"
       :type integer)

  (mon nil
       :accessor-init (format-time-string "%b" (ts-unix struct))
       :aliases (month-abbr)
       :constructor "%b")
  (month nil
         :accessor-init (format-time-string "%B" (ts-unix struct))
         :aliases (month-name)
         :constructor "%B")

  (tz-abbr nil
           :accessor-init (format-time-string "%Z" (ts-unix struct))
           :constructor "%Z")
  (tz-offset nil
             :accessor-init (format-time-string "%z" (ts-unix struct))
             :constructor "%z")
  ;; MAYBE: Add tz-offset-minutes

  (internal nil
            :accessor-init (apply #'encode-time (decode-time (ts-unix struct))))
  (unix nil
        :accessor-init (pcase-let* (((cl-struct ts second minute hour dom moy year) cl-x))
                         (if (and second minute hour dom moy year)
                             (float-time (encode-time second minute hour dom moy year))
                           (float-time)))))

(defmacro ts-define-fill ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let ((slots (->> (cl-struct-slot-info 'ts)
                    (-map #'car)
                    (--select (not (member it '(unix internal cl-tag-slot)))))))
    `(defun ts-fill (ts &optional force)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
If FORCE is non-nil, update already-filled slots."
       (when force
         ,@(cl-loop for slot in slots
                    for accessor = (intern (concat "ts-" (symbol-name slot)))
                    collect `(setf (,accessor ts) nil)))
       ,@(cl-loop for slot in slots
                  for accessor = (intern (concat "ts-" (symbol-name slot)))
                  collect `(,accessor ts))
       ts)))
(ts-define-fill)

(defmacro ts-define-fill2 ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let* ((slots (->> (cl-struct-slot-info 'ts)
                     (--select (and (not (member (car it) '(unix internal cl-tag-slot)))
                                    (plist-get (cddr it) :constructor)))

                     (--map (list (intern (concat ":" (symbol-name (car it))))
                                  (cddr it)))))
         (keywords (-map #'first slots))
         (constructors (->> slots
                            (--map (plist-get (cadr it) :constructor))
                            -non-nil))
         (types (--map (plist-get (cadr it) :type) slots))
         (format-string (s-join "\f" constructors)))
    `(defun ts-fill2 (ts)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
If FORCE is non-nil, update already-filled slots."
       (let* ((time-values (split-string (format-time-string ,format-string (ts-unix ts)) "\f"))
              (args (cl-loop for type in ',types
                             for tv in time-values
                             for keyword in ',keywords
                             append (list keyword (pcase type
                                                    ('integer (string-to-number tv))
                                                    (_ tv))))))
         (apply #'make-ts :unix (ts-unix ts) args)))))
(ts-define-fill2)

(bench-multi :times 1000
  :ensure-equal t
  :forms (("old" (ts-fill (make-ts :unix 1544410412.2087605)))
          ("new" (ts-fill2 (make-ts :unix 1544410412.2087605)))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
new5.850.15348223400.0
oldslowest0.89782308210.25289141199999676

This compares both ways defined with defun. The cl-defmethod dispatch overhead is very significant:

(unintern 'ts-fill)
(unintern 'ts-fill2)

(ts-defstruct ts
  (hour nil
        :accessor-init (string-to-number (format-time-string "%H" (ts-unix struct)))
        :aliases (H)
        :constructor "%H"
        :type integer)
  (minute nil
          :accessor-init (string-to-number (format-time-string "%M" (ts-unix struct)))
          :aliases (min M)
          :constructor "%M"
          :type integer)
  (second nil
          :accessor-init (string-to-number (format-time-string "%S" (ts-unix struct)))
          :aliases (sec S)
          :constructor "%S"
          :type integer)
  (dom nil
       :accessor-init (string-to-number (format-time-string "%d" (ts-unix struct)))
       :aliases (d)
       :constructor "%d"
       :type integer)
  (moy nil
       :accessor-init (string-to-number (format-time-string "%m" (ts-unix struct)))
       :aliases (m month-of-year)
       :constructor "%m"
       :type integer)
  (year nil
        :accessor-init (string-to-number (format-time-string "%Y" (ts-unix struct)))
        :aliases (Y)
        :constructor "%Y"
        :type integer)

  (dow nil
       :accessor-init (string-to-number (format-time-string "%w" (ts-unix struct)))
       :aliases (day-of-week)
       :constructor "%w"
       :type integer)
  (day nil
       :accessor-init (format-time-string "%a" (ts-unix struct))
       :aliases (day-abbr)
       :constructor "%a")
  (day-full nil
            :accessor-init (format-time-string "%A" (ts-unix struct))
            :aliases (day-name)
            :constructor "%A")
  ;; (doe nil
  ;;      :accessor-init (days-between (format-time-string "%Y-%m-%d 00:00:00" (ts-unix struct))
  ;;                                   "1970-01-01 00:00:00")
  ;;      :aliases (day-of-epoch))
  (doy nil
       :accessor-init (string-to-number (format-time-string "%j" (ts-unix struct)))
       :aliases (day-of-year)
       :constructor "%j"
       :type integer)

  (woy nil
       :accessor-init (string-to-number (format-time-string "%V" (ts-unix struct)))
       :aliases (week week-of-year)
       :constructor "%V"
       :type integer)

  (mon nil
       :accessor-init (format-time-string "%b" (ts-unix struct))
       :aliases (month-abbr)
       :constructor "%b")
  (month nil
         :accessor-init (format-time-string "%B" (ts-unix struct))
         :aliases (month-name)
         :constructor "%B")

  (tz-abbr nil
           :accessor-init (format-time-string "%Z" (ts-unix struct))
           :constructor "%Z")
  (tz-offset nil
             :accessor-init (format-time-string "%z" (ts-unix struct))
             :constructor "%z")
  ;; MAYBE: Add tz-offset-minutes

  (internal nil
            :accessor-init (apply #'encode-time (decode-time (ts-unix struct))))
  (unix nil
        :accessor-init (pcase-let* (((cl-struct ts second minute hour dom moy year) cl-x))
                         (if (and second minute hour dom moy year)
                             (float-time (encode-time second minute hour dom moy year))
                           (float-time)))))

(defmacro ts-define-fill ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let ((slots (->> (cl-struct-slot-info 'ts)
                    (-map #'car)
                    (--select (not (member it '(unix internal cl-tag-slot)))))))
    `(cl-defmethod ts-fill ((ts ts) &optional force)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
  If FORCE is non-nil, update already-filled slots."
       (when force
         ,@(cl-loop for slot in slots
                    for accessor = (intern (concat "ts-" (symbol-name slot)))
                    collect `(setf (,accessor ts) nil)))
       ,@(cl-loop for slot in slots
                  for accessor = (intern (concat "ts-" (symbol-name slot)))
                  collect `(,accessor ts))
       ts)))
(ts-define-fill)

(defmacro ts-define-fill2 ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let* ((slots (->> (cl-struct-slot-info 'ts)
                     (--select (and (not (member (car it) '(unix internal cl-tag-slot)))
                                    (plist-get (cddr it) :constructor)))

                     (--map (list (intern (concat ":" (symbol-name (car it))))
                                  (cddr it)))))
         (keywords (-map #'first slots))
         (constructors (->> slots
                            (--map (plist-get (cadr it) :constructor))
                            -non-nil))
         (types (--map (plist-get (cadr it) :type) slots))
         (format-string (s-join "\f" constructors)))
    `(defun ts-fill2 (ts)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
If FORCE is non-nil, update already-filled slots."
       (let* ((time-values (split-string (format-time-string ,format-string (ts-unix ts)) "\f"))
              (args (cl-loop for type in ',types
                             for tv in time-values
                             for keyword in ',keywords
                             append (list keyword (pcase type
                                                    ('integer (string-to-number tv))
                                                    (_ tv))))))
         (apply #'make-ts :unix (ts-unix ts) args)))))
(ts-define-fill2)

(bench-multi :times 1000
  :ensure-equal t
  :forms (("old" (ts-fill (make-ts :unix 1544410412.2087605)))
          ("new" (ts-fill2 (make-ts :unix 1544410412.2087605)))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
new2.510.1502957790000000200.0
oldslowest0.37747452900.0

Comparing defun and cl-defmethod

(unintern 'ts-fill-method)
(defmacro ts-define-fill-method ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let ((slots (->> (cl-struct-slot-info 'ts)
                    (-map #'car)
                    (--select (not (member it '(unix internal cl-tag-slot)))))))
    `(cl-defmethod ts-fill-method ((ts ts) &optional force)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
 If FORCE is non-nil, update already-filled slots."
       (when force
         ,@(cl-loop for slot in slots
                    for accessor = (intern (concat "ts-" (symbol-name slot)))
                    collect `(setf (,accessor ts) nil)))
       ,@(cl-loop for slot in slots
                  for accessor = (intern (concat "ts-" (symbol-name slot)))
                  collect `(,accessor ts))
       ts)))
(ts-define-fill-method)

(unintern 'ts-fill-defun)
(defmacro ts-define-fill-defun ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let ((slots (->> (cl-struct-slot-info 'ts)
                    (-map #'car)
                    (--select (not (member it '(unix internal cl-tag-slot)))))))
    `(defun ts-fill-defun (ts &optional force)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
 If FORCE is non-nil, update already-filled slots."
       (when force
         ,@(cl-loop for slot in slots
                    for accessor = (intern (concat "ts-" (symbol-name slot)))
                    collect `(setf (,accessor ts) nil)))
       ,@(cl-loop for slot in slots
                  for accessor = (intern (concat "ts-" (symbol-name slot)))
                  collect `(,accessor ts))
       ts)))
(ts-define-fill-defun)

(bench-multi :times 10
  :ensure-equal t
  :forms (("cl-defmethod" (ts-fill-method (make-ts :unix 1544410412.2087605)))
          ("defun" (ts-fill-defun (make-ts :unix 1544410412.2087605)))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
cl-defmethod1.710.0038986100.0
defunslowest0.00664715200.0

With byte-compilation:

(unintern 'ts-fill-method)
(defmacro ts-define-fill-method ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let ((slots (->> (cl-struct-slot-info 'ts)
                    (-map #'car)
                    (--select (not (member it '(unix internal cl-tag-slot)))))))
    `(cl-defmethod ts-fill-method ((ts ts) &optional force)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
 If FORCE is non-nil, update already-filled slots."
       (when force
         ,@(cl-loop for slot in slots
                    for accessor = (intern (concat "ts-" (symbol-name slot)))
                    collect `(setf (,accessor ts) nil)))
       ,@(cl-loop for slot in slots
                  for accessor = (intern (concat "ts-" (symbol-name slot)))
                  collect `(,accessor ts))
       ts)))
(byte-compile (ts-define-fill-method))

(unintern 'ts-fill-defun)
(defmacro ts-define-fill-defun ()
  "Define `ts-fill' method that fills all applicable slots of `ts' object from its `unix' slot."
  (let ((slots (->> (cl-struct-slot-info 'ts)
                    (-map #'car)
                    (--select (not (member it '(unix internal cl-tag-slot)))))))
    `(defun ts-fill-defun (ts &optional force)
       "Fill all slots of timestamp TS from Unix timestamp and return TS.
 If FORCE is non-nil, update already-filled slots."
       (when force
         ,@(cl-loop for slot in slots
                    for accessor = (intern (concat "ts-" (symbol-name slot)))
                    collect `(setf (,accessor ts) nil)))
       ,@(cl-loop for slot in slots
                  for accessor = (intern (concat "ts-" (symbol-name slot)))
                  collect `(,accessor ts))
       ts)))
(byte-compile (ts-define-fill-defun))

(bench-multi :times 10
  :ensure-equal t
  :forms (("cl-defmethod" (ts-fill-method (make-ts :unix 1544410412.2087605)))
          ("defun" (ts-fill-defun (make-ts :unix 1544410412.2087605)))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
defun1.070.00367768200.0
cl-defmethodslowest0.00393350100.0

This seems to show that cl-defmethod may be faster when not byte-compiled, but defun is faster when byte-compiled…?

ts-incf vs. ts-incf*

ts-incf* uses cl-struct-slot-value to make access slightly easier by only having to specify the slot instead of calling the accessor. It’s nice to see that performance is identical!

(bench-multi :times 1000
  :ensure-equal t
  :forms (("ts-incf" (let ((ts (ts-now)))
                       (ts-incf (ts-dom ts) 5)
                       (ts-format nil ts)))
          ("ts-incf*" (let ((ts (ts-now)))
                        (ts-incf (ts-dom ts) 5)
                        (ts-format nil ts)))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts-incf1.000.11900249700.0
ts-incf*slowest0.1190788620000000100.0

Making a new ts vs. blanking fields

Interestingly, not only is making a new ts faster, but it causes less GC!

(let* ((a (ts-now)))
  (bench-multi :times 100000
    :ensure-equal t
    :forms (("New" (let ((ts (copy-ts a)))
                     (setq ts (ts-fill ts))
                     (make-ts :unix (ts-unix ts))))
            ("Blanking" (let ((ts (copy-ts a)))
                          (setq ts (ts-fill ts))
                          (ts-reset ts))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
New1.1616.022026285377.72851086399999
Blankingslowest18.664577402428.754392806999988

ts-inc vs. ts-incf vs. cl-incf vs…

ts-inc does more work than cl-incf, so it should be slower. But with cl-incf we have to call ts-fill and ts-update manually.

Note: We call ts-format to ensure that each form is returning the same thing, because e.g. ts-inc returns the timestamp, while ts-incf returns the new slot value.

(let ((ts (ts-now)))
  (bench-multi :times 1000 :ensure-equal t
    :forms (("ts-inc" (->> (copy-ts ts)
                           (ts-inc 'hour 72)
                           (ts-inc 'minute 10)
                           (ts-format nil)))
            ("ts-incf" (let ((ts (copy-ts ts)))
                         (ts-incf (ts-hour ts) 72)
                         (ts-incf (ts-minute ts) 10)
                         (ts-format nil ts)))
            ("cl-incf" (let ((ts (copy-ts ts)))
                         (setq ts (ts-fill ts))
                         (cl-incf (ts-hour ts) 72)
                         (cl-incf (ts-minute ts) 10)
                         (setq ts (ts-update ts))
                         (ts-format nil ts)))
            ("ts-adjust" (let ((ts (copy-ts ts)))
                           (ts-format nil (ts-adjust 'hour 72 'minute 10 ts))))
            ("ts-adjustf" (let ((ts (copy-ts ts)))
                            (ts-format nil (ts-adjustf ts 'hour 72 'minute 10))))
            ("manually-expanded ts-adjustf w/accessors"
             (let ((ts (ts-fill (copy-ts ts))) )
               (cl-incf (ts-hour ts) 72)
               (cl-incf (ts-minute ts) 10)
               (setq ts (ts-update ts))
               (ts-format nil ts)))
            ("manually-expanded ts-adjustf w/cl-struct-slot-value"
             (let ((ts (copy-ts ts)))
               (ts-format nil (let ((g3706 (ts-fill ts)))
                                (cl-incf (cl-struct-slot-value 'ts 'hour g3706) 72)
                                (cl-incf (cl-struct-slot-value 'ts 'minute g3706) 10)
                                (setf ts (make-ts :unix (ts-unix (ts-update g3706)))))))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts-adjustf1.000.11852490500.0
cl-incf1.000.11889237400.0
manually-expanded ts-adjustf w/accessors1.080.11889639500.0
manually-expanded ts-adjustf w/cl-struct-slot-value1.110.12793799800.0
ts-adjust1.470.1416276640000000100.0
ts-incf1.000.20802690200.0
ts-incslowest0.20824872400.0

cl-struct-slot-value seems a bit slower than calling accessors. I understand why this is so in non-byte-compiled code, but it’s defined with define-inline, and its comments say that the byte-compiler resolves the array positions at compile time, so it seems like it ought to be just as fast as calling the accessors.

ts-adjust vs ts-inc vs ts-adjustf for only one adjustment

(let ((ts (ts-now)))
  (bench-multi :times 1000 :ensure-equal t
    :forms (("ts-inc" (->> (copy-ts ts)
                           (ts-inc 'hour 72)
                           (ts-format nil)))
            ("ts-adjust" (let ((ts (copy-ts ts)))
                           (ts-format nil (ts-adjust 'hour 72 ts))))
            ("ts-adjustf" (let ((ts (copy-ts ts)))
                            (ts-adjustf ts 'hour 72)
                            (ts-format nil ts))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts-adjustf1.100.11536391600.0
ts-inc1.060.126593700.0
ts-adjustslowest0.1344314820000000200.0

Getting quotient and remainder

(let ((divisor 31536000)
      (a 1544930832)
      (b 15103636150))
  (bench-multi :times 100000 :ensure-equal t
    :forms (("Divide and multiply" (let* ((orig-value a)
                                          (new-value (/ orig-value divisor)))
                                     (- orig-value (* new-value divisor))))
            ("Divide and mod" (let* ((orig-value a)
                                     (new-value (/ orig-value divisor)))
                                (% orig-value divisor))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
Divide and mod1.150.02098712699999999800.0
Divide and multiplyslowest0.02408373400.0
(let*((a 1544930832)
      (b 15103636150)
      (diff (- a b)))
  (bench-multi :times 1000 :ensure-equal t
    :forms (("ts-human-duration" (ts-human-duration diff))
            ("ts-human-duration-mod" (ts-human-duration-mod diff)))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts-human-duration-mod1.060.01043329600.0
ts-human-durationslowest0.01105325300.0

So it’s slightly faster to use % than to calculate the remainder manually.

ts-format vs. ts-format2

The new one allows more flexible arguments, but may be slower. Let’s find out:

(let ((now (ts-now)))
  (bench-multi-lexical :times 10000
    :forms (("ts-format" (list (ts-format nil now)
                               (ts-format "%Y" now)
                               (ts-format "%Y")
                               (ts-format nil now)
                               (ts-format nil)))
            ("ts-format2" (list (ts-format2 now)
                                (ts-format2 "%Y" now)
                                (ts-format2 "%Y")
                                (ts-format2 nil now)
                                (ts-format2))))))
Formx faster than nextTotal runtime# of GCsTotal GC runtime
ts-format1.031.15820700
ts-format2slowest1.19136900

It seems to be 2-3% slower, which is about 0.03 seconds across 10,000 iterations. Should be fine. Let’s do it.

Examples

More code examples.

ts-week-span function

(defun ts-week-span (ts)
  "Return a cons (BEG-TS . END-TS) spanning the week containing timestamp TS."
  (let* (
         ;; We start by calculating the offsets for the beginning and
         ;; ending timestamps using the current day of the week. Note
         ;; that the `ts-dow' slot uses the "%w" format specifier, which
         ;; counts from Sunday to Saturday as a number from 0 to 6.
         (adjust-beg-day (- (ts-dow ts)))
         (adjust-end-day (- 6 (ts-dow ts)))
         ;; Make beginning/end timestamps based on `ts', with adjusted
         ;; day and hour/minute/second values. These functions return
         ;; new timestamps, so `ts' is unchanged.
         (beg (thread-last ts
                ;; `ts-adjust' makes relative adjustments to timestamps.
                (ts-adjust 'day adjust-beg-day)
                ;; `ts-apply' applies absolute values to timestamps.
                (ts-apply :hour 0 :minute 0 :second 0)))
         (end (thread-last ts
                (ts-adjust 'day adjust-end-day)
                (ts-apply :hour 23 :minute 59 :second 59))))
    (cons beg end)))

(-let* (;; Bind the default format string for `ts-format', so the
        ;; results are easy to understand.
        (ts-default-format "%a, %Y-%m-%d %H:%M:%S %z")
        ((beg . end) (ts-week-span (make-ts :unix 0))))
  ;; Finally, format the timestamps.
  (list :epoch-week-beg (ts-format beg)
        :epoch-week-end (ts-format end)))

;; This produces:

;;=> (:epoch-week-beg "Sun, 1969-12-28 00:00:00 -0600"
;;    :epoch-week-end "Sat, 1970-01-03 23:59:59 -0600")