PERF: Add cache keyword to to_datetime (#11665)
State: closed

 - [x] closes #11665
 - [x] tests added / passed
 - [x] passes ``git diff upstream/master -u -- "*.py" | flake8 --diff``
 - [x] whatsnew entry

Added a `cache` keyword to `to_datetime` to speedup parsing of duplicate dates:

Some notes:

1) I defaulted `cache=False` i.e. don't use a cache to parse the dates. Should the default be `True`? 

2) I used `pd.unique()` to identify unique dates, and the current implementation did not accept a tuple of strings (objects). I added `tuple_to_object_array` and patched ` _ensure_arraylike` to fix this.

3) There is currently an included test that fails due to a case when using `to_datetime(..., utc=True)` with a `Series`. I am inclined to believe `In[5]` should have been the existing behavior. Thoughts?

```
In [2]: test_dates = ['20130101 00:00:00'] * 10

In [3]: s = pd.Series(test_dates)

# Same as existing behavior
In [4]: pd.to_datetime(s, utc=True, cache_datetime=False)
Out[4]: 
0   2013-01-01
1   2013-01-01
2   2013-01-01
3   2013-01-01
4   2013-01-01
5   2013-01-01
6   2013-01-01
7   2013-01-01
8   2013-01-01
9   2013-01-01
dtype: datetime64[ns]

In [5]: pd.to_datetime(s, utc=True, cache_datetime=True)
Out[5]: 
0   2013-01-01 00:00:00+00:00
1   2013-01-01 00:00:00+00:00
2   2013-01-01 00:00:00+00:00
3   2013-01-01 00:00:00+00:00
4   2013-01-01 00:00:00+00:00
5   2013-01-01 00:00:00+00:00
6   2013-01-01 00:00:00+00:00
7   2013-01-01 00:00:00+00:00
8   2013-01-01 00:00:00+00:00
9   2013-01-01 00:00:00+00:00
dtype: datetime64[ns, UTC]

```

Labels:
name: Enhancement
name: Datetime
description: Datetime data dtype
name: API Design

Comments:
Let me address 1 and 3 first: IIUC, this caching should only speed up performance.  The results you get with or without caching should not be different.  The fact that you are getting different results indicates something is going weird with the caching.

In light of that reasoning, I would expect that caching should default to `True`.
For 2, I think that's worthy enough as its own PR.  I'm not sure I fully agree with the patch (i.e. why do we need another special-case `to_object_array` method), but even so, you should separate that out as another PR and submit that for review (along with tests).

After that gets merged, you can rebase this PR onto that one.
@gfyoung In regards to my 3rd point, I agree that the results should be the same whether `cache_datetime=True/False`. I am just curious whether `pd.to_datetime(Series(...), utc=True)` should return a `dtype: datetime64[ns, UTC]` in the first place.

```
In [9]: pd.__version__
Out[9]: u'0.20.3'

In [10]: test_dates = ['20130101 00:00:00'] * 10

In [11]: s = pd.Series(test_dates)

# Should this result have a datetime64[ns, UTC] dtype like Out [13]?
In [12]: pd.to_datetime(s, utc=True)
Out[12]:
0   2013-01-01
1   2013-01-01
2   2013-01-01
3   2013-01-01
4   2013-01-01
5   2013-01-01
6   2013-01-01
7   2013-01-01
8   2013-01-01
9   2013-01-01
dtype: datetime64[ns]

In [13]: pd.to_datetime(test_dates, utc=True)
Out[13]:
DatetimeIndex(['2013-01-01', '2013-01-01', '2013-01-01', '2013-01-01',
               '2013-01-01', '2013-01-01', '2013-01-01', '2013-01-01',
               '2013-01-01', '2013-01-01'],
              dtype='datetime64[ns, UTC]', freq=None)

# I think this is essentially the result of my caching implementation. 
In [14]: pd.Series([pd.Timestamp('20130101 00:00:00', tz='utc')]*10)
Out[14]:
0   2013-01-01 00:00:00+00:00
1   2013-01-01 00:00:00+00:00
2   2013-01-01 00:00:00+00:00
3   2013-01-01 00:00:00+00:00
4   2013-01-01 00:00:00+00:00
5   2013-01-01 00:00:00+00:00
6   2013-01-01 00:00:00+00:00
7   2013-01-01 00:00:00+00:00
8   2013-01-01 00:00:00+00:00
9   2013-01-01 00:00:00+00:00
dtype: datetime64[ns, UTC]
```

This discussion may be more appropriate in a separate issue if it is one. I may not hit this though once I refactor this implementation. 
> I am just curious whether pd.to_datetime(Series(...), utc=True) should return a dtype: datetime64[ns, UTC] in the first place.

I think it should.  `datetime64` is standard, and you specified `utc=True`.
there is another issue about utc=True

so it's out of scope for this PR
Thanks for confirming @jreback and @gfyoung. I will work on implementing your suggestions.
can you rebase / update
Hello @mroeschke! Thanks for updating the PR.







Cheers ! There are no PEP8 issues in this Pull Request. :beers: 

##### Comment last updated on November 11, 2017 at 18:39 Hours UTC
Here are the results from `timeseries.ToDatetime` ASVs.

```
>>>asv continuous -f 1.1 upstream/master fix_11665 -b timeseries.ToDatetime
      before           after         ratio
     [e0fe5cc6]       [72e99da0]
+     7.91±0.04ms           16.5ms     2.08  timeseries.ToDatetime.time_iso8601_format_no_sep
+     8.07±0.03ms      16.5±0.04ms     2.04  timeseries.ToDatetime.time_iso8601_nosep
+     8.18±0.01ms      16.4±0.03ms     2.00  timeseries.ToDatetime.time_iso8601
+     8.34±0.02ms      16.5±0.01ms     1.98  timeseries.ToDatetime.time_iso8601_format
+     12.5±0.07ms      14.8±0.05ms     1.18  timeseries.ToDatetime.time_format_YYYYMMDD
-        3.49±0ms         2.98±0ms     0.86  timeseries.ToDatetime.time_cache_with_dup_string_dates_and_format
-     3.54±0.01ms      2.98±0.01ms     0.84  timeseries.ToDatetime.time_cache_with_dup_string_dates
-     8.96±0.03ms      7.12±0.01ms     0.79  timeseries.ToDatetime.time_cache_with_dup_seconds_and_unit
-           2.11s       44.1±0.2ms     0.02  timeseries.ToDatetime.time_format_exact
-           2.02s      30.5±0.04ms     0.02  timeseries.ToDatetime.time_format_no_exact
-       407±0.3ms      3.35±0.01ms     0.01  timeseries.ToDatetime.time_cache_with_dup_string_tzoffset_dates

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
```

Addtionally, I edited most all the tests in `pandas/tests/indexes/datetimes/test_tools.py` to include `@pytest.mark.parametrize` on the `cache` keyword (`True` and `False`)
# [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=h1) Report
> Merging [#17077](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=desc) into [master](https://codecov.io/gh/pandas-dev/pandas/commit/5279a172a86ac22250c5a382708e23917df79744?src=pr&el=desc) will **decrease** coverage by `0.03%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/pandas-dev/pandas/pull/17077/graphs/tree.svg?token=eZ4WkYLtcO&src=pr&height=150&width=650)](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master   #17077      +/-   ##
==========================================
- Coverage   91.25%   91.22%   -0.04%     
==========================================
  Files         163      163              
  Lines       49810    49829      +19     
==========================================
- Hits        45456    45455       -1     
- Misses       4354     4374      +20
```

| Flag | Coverage Δ | |
|---|---|---|
| #multiple | `89.02% <100%> (-0.02%)` | :arrow_down: |
| #single | `40.32% <50%> (-0.06%)` | :arrow_down: |

| [Impacted Files](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pandas/core/indexes/datetimes.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvaW5kZXhlcy9kYXRldGltZXMucHk=) | `95.53% <ø> (ø)` | :arrow_up: |
| [pandas/core/tools/datetimes.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvdG9vbHMvZGF0ZXRpbWVzLnB5) | `86.23% <100%> (+0.98%)` | :arrow_up: |
| [pandas/io/gbq.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL2lvL2dicS5weQ==) | `25% <0%> (-58.34%)` | :arrow_down: |
| [pandas/plotting/\_converter.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL3Bsb3R0aW5nL19jb252ZXJ0ZXIucHk=) | `63.38% <0%> (-1.82%)` | :arrow_down: |
| [pandas/core/frame.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvZnJhbWUucHk=) | `97.73% <0%> (-0.1%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=footer). Last update [5279a17...75eccc5](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

# [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=h1) Report
> Merging [#17077](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=desc) into [master](https://codecov.io/gh/pandas-dev/pandas/commit/3493abaa9c47e39b410752833c901fd27f5b3a76?src=pr&el=desc) will **decrease** coverage by `0.03%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/pandas-dev/pandas/pull/17077/graphs/tree.svg?token=eZ4WkYLtcO&width=650&src=pr&height=150)](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master   #17077      +/-   ##
==========================================
- Coverage   91.42%   91.39%   -0.04%     
==========================================
  Files         163      163              
  Lines       50064    50091      +27     
==========================================
+ Hits        45773    45779       +6     
- Misses       4291     4312      +21
```

| Flag | Coverage Δ | |
|---|---|---|
| #multiple | `89.2% <100%> (-0.02%)` | :arrow_down: |
| #single | `40.36% <53.12%> (-0.06%)` | :arrow_down: |

| [Impacted Files](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pandas/core/tools/datetimes.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvdG9vbHMvZGF0ZXRpbWVzLnB5) | `84.48% <100%> (+1.51%)` | :arrow_up: |
| [pandas/io/gbq.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL2lvL2dicS5weQ==) | `25% <0%> (-58.34%)` | :arrow_down: |
| [pandas/plotting/\_converter.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL3Bsb3R0aW5nL19jb252ZXJ0ZXIucHk=) | `63.38% <0%> (-1.82%)` | :arrow_down: |
| [pandas/core/frame.py](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=tree#diff-cGFuZGFzL2NvcmUvZnJhbWUucHk=) | `97.8% <0%> (-0.1%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=footer). Last update [3493aba...07fa22d](https://codecov.io/gh/pandas-dev/pandas/pull/17077?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

Tests are passing now and here are the latest asv results:

```
>>> asv continuous -f 1.1 upstream/master fix_11665 -b timeseries.ToDatetime
 before           after         ratio
     [37860a5f]       [bdff633d]
+     8.05±0.05ms      16.3±0.09ms     2.02  timeseries.ToDatetime.time_iso8601
+     7.99±0.06ms      15.8±0.09ms     1.98  timeseries.ToDatetime.time_iso8601_nosep
+     8.53±0.06ms       16.5±0.3ms     1.94  timeseries.ToDatetime.time_iso8601_format_no_sep
+     8.72±0.02ms      16.0±0.04ms     1.84  timeseries.ToDatetime.time_iso8601_format
+      12.5±0.1ms      14.6±0.08ms     1.17  timeseries.ToDatetime.time_format_YYYYMMDD
-        3.53±0ms         3.14±0ms     0.89  timeseries.ToDatetime.time_cache_with_dup_string_dates_and_format
-     8.18±0.03ms         7.25±0ms     0.89  timeseries.ToDatetime.time_cache_with_dup_seconds_and_unit
-           2.07s      47.7±0.02ms     0.02  timeseries.ToDatetime.time_format_exact
-           1.96s      31.7±0.02ms     0.02  timeseries.ToDatetime.time_format_no_exact
-       399±0.5ms      3.51±0.01ms     0.01  timeseries.ToDatetime.time_cache_with_dup_string_tzoffset_dates

SOME BENCHMARKS HAVE CHANGED SIGNIFICANTLY.
```
what is the 2x slowdown on some of the existing tests? 
It looks like the iso8601 path is so fast the caching always hurts - I think that makes sense, that conversion shouldn't be much more expensive than hashing, but not sure how to handle it.
The 2x slowdown occurred with iso8601 strings dates without any duplicates. I've attached the profile below of the benchmark with the largest slowdown. It looks like it's expensive put the strings (objects) through  `algorithms.unique` and then continue down the regular conversion path since there are no duplicates. 
```
In [3]: rng = date_range(start='1/1/2000', periods=20000, freq='H')

In [4]: strings = rng.strftime('%Y-%m-%d %H:%M:%S').tolist()

In [5]: cProfile.run('to_datetime(strings)', sort='cumtime')
         273 function calls (270 primitive calls) in 0.017 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.017    0.017 <string>:1(<module>)
        1    0.001    0.001    0.017    0.017 datetimes.py:39(to_datetime)
        1    0.000    0.000    0.009    0.009 datetimes.py:207(_convert_listlike)
        1    0.008    0.008    0.008    0.008 {pandas._libs.tslib.array_to_datetime}
        1    0.000    0.000    0.007    0.007 datetimes.py:313(_maybe_convert_cache)
        1    0.000    0.000    0.007    0.007 algorithms.py:276(unique)
        1    0.005    0.005    0.005    0.005 {method 'unique' of 'pandas._libs.hashtable.PyObjectHashTable' objects}
        1    0.000    0.000    0.001    0.001 algorithms.py:164(_ensure_arraylike)
        2    0.001    0.001    0.001    0.001 {pandas._libs.lib.infer_dtype}
        1    0.000    0.000    0.001    0.001 algorithms.py:132(_reconstruct_data)
        2    0.001    0.000    0.001    0.000 {numpy.core.multiarray.array}
        1    0.000    0.000    0.001    0.001 algorithms.py:189(_get_hashtable_algo)
        1    0.000    0.000    0.000    0.000 {method 'astype' of 'numpy.ndarray' objects}
      114    0.000    0.000    0.000    0.000 {isinstance}
    11/10    0.000    0.000    0.000    0.000 common.py:1773(_get_dtype_type)
        1    0.000    0.000    0.000    0.000 {pandas._libs.lib.list_to_object_array}
      2/1    0.000    0.000    0.000    0.000 _decorators.py:86(wrapper)
        1    0.000    0.000    0.000    0.000 datetimes.py:269(__new__)
        3    0.000    0.000    0.000    0.000 common.py:1545(is_bool_dtype)
        1    0.000    0.000    0.000    0.000 algorithms.py:39(_ensure_data)
        6    0.000    0.000    0.000    0.000 dtypes.py:85(is_dtype)
        3    0.000    0.000    0.000    0.000 common.py:334(is_datetime64tz_dtype)
        3    0.000    0.000    0.000    0.000 common.py:297(is_datetime64_dtype)
        1    0.000    0.000    0.000    0.000 datetimes.py:579(_simple_new)
       12    0.000    0.000    0.000    0.000 generic.py:7(_check)
        3    0.000    0.000    0.000    0.000 common.py:478(is_categorical_dtype)
        1    0.000    0.000    0.000    0.000 common.py:1049(is_datetime64_ns_dtype)
        2    0.000    0.000    0.000    0.000 dtypes.py:428(construct_from_string)
        1    0.000    0.000    0.000    0.000 common.py:1722(_get_dtype)
        3    0.000    0.000    0.000    0.000 common.py:85(is_object_dtype)
       18    0.000    0.000    0.000    0.000 {getattr}
        2    0.000    0.000    0.000    0.000 common.py:409(is_period_dtype)
        3    0.000    0.000    0.000    0.000 abc.py:128(__instancecheck__)
        2    0.000    0.000    0.000    0.000 inference.py:234(is_list_like)
        2    0.000    0.000    0.000    0.000 dtypes.py:370(__new__)
        2    0.000    0.000    0.000    0.000 common.py:873(is_unsigned_integer_dtype)
        2    0.000    0.000    0.000    0.000 dtypes.py:554(is_dtype)
        2    0.000    0.000    0.000    0.000 common.py:824(is_signed_integer_dtype)
        2    0.000    0.000    0.000    0.000 common.py:1493(is_float_dtype)
        8    0.000    0.000    0.000    0.000 {hasattr}
       11    0.000    0.000    0.000    0.000 {issubclass}
      6/5    0.000    0.000    0.000    0.000 {len}
        1    0.000    0.000    0.000    0.000 common.py:442(is_interval_dtype)
        1    0.000    0.000    0.000    0.000 dtypes.py:676(is_dtype)
        4    0.000    0.000    0.000    0.000 _weakrefset.py:70(__contains__)
        5    0.000    0.000    0.000    0.000 {method 'startswith' of 'str' objects}
        1    0.000    0.000    0.000    0.000 numeric.py:463(asarray)
        2    0.000    0.000    0.000    0.000 dtypes.py:270(construct_from_string)
        2    0.000    0.000    0.000    0.000 {method 'search' of '_sre.SRE_Pattern' objects}
        4    0.000    0.000    0.000    0.000 {method 'pop' of 'dict' objects}
        2    0.000    0.000    0.000    0.000 {pandas._libs.algos.ensure_object}
        1    0.000    0.000    0.000    0.000 base.py:558(__len__)
        1    0.000    0.000    0.000    0.000 base.py:552(_reset_identity)
        1    0.000    0.000    0.000    0.000 {built-in method __new__ of type object at 0x7f88fb67a4c0}
        1    0.000    0.000    0.000    0.000 {pandas._libs.tslibs.timezones.maybe_get_tz}
        1    0.000    0.000    0.000    0.000 {method 'lower' of 'str' objects}
        1    0.000    0.000    0.000    0.000 base.py:467(_deepcopy_if_needed)
        1    0.000    0.000    0.000    0.000 frequencies.py:391(to_offset)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
```
Given the potential slowdown, can't we add it as optional keyword? (so False by default)
you need to benchmark on different sizes with and w/o duplicates to figure out the cutoff.

IOW n=[1000, 10000, 100000] or something

I suspect just making the cutoff higher will make ``cache=True`` a good defaultl.

also its is quite fast do call ``pd.Index(...).is_unique`` after the size cutoff, but before actual parsing.
Apologies for the delay on my end with this PR.

It doesn't appear that there's a cutoff where a `cache=True` default avoids a slowdown, unless we know that all the dates are duplicates. Might be worth defaulting to `cache=False` with guidelines on when to set it to True. 

I've benchmarked the largest slowdown (parsing a date string) altering the number of dates to parse and different proportion of duplicate values (0 = all unique, 1 = all duplicate). The table below shows the timing ratios of `cache=True` time / `cache=False` times:
```
Proportion of Duplicate Values       0.0       0.2       0.4       0.6  \
Number of Values                                                         
1000.0                          1.717877  5.202867  4.594803  1.009279   
10000.0                         1.413974  3.235636  2.709920  2.395477   
100000.0                        1.878788  4.001020  3.401219  2.504916   
1000000.0                       2.320983  5.392797  3.579101  3.050970   

Proportion of Duplicate Values       0.8       1.0  
Number of Values                                    
1000.0                          1.009684  3.867605  
10000.0                         2.008572  1.289517  
100000.0                        1.798609  0.946278  
1000000.0                       2.138899  0.969332 
```

Code to generate table:
```
from timeit import Timer
from datetime import datetime, timedelta

import pandas as pd
import numpy as np
sizes = np.logspace(3, 6, 4)
proportions = np.linspace(0, 1, 6)
base = '1901-01-01 00:00:00'
fmt = '%Y-%m-%d %H:%M:%S'
unique_base = datetime.strptime(base, fmt) + timedelta(seconds=1)
results = []
for size in sizes:
    for prop_dupes in proportions:
        num_dupes = int(size * prop_dupes)
        num_uniques = int(size * (1 - prop_dupes))
        uniques = pd.date_range(unique_base, freq='s', periods=num_uniques).strftime(fmt).tolist()
        dupes = [base] * num_dupes
        data = dupes + uniques
        exec_time_true = Timer(lambda : pd.to_datetime(data, cache=True)).repeat(number=10)
        exec_time_false = Timer(lambda : pd.to_datetime(data, cache=False)).repeat(number=10)
        true_false_time_ratio = min(exec_time_true) / min(exec_time_false)
        result = [size, prop_dupes, true_false_time_ratio]
        results.append(result)
num_results = pd.DataFrame(results, columns=['Number of Values',
                                             'Proportion of Duplicate Values',
                                             'Cache True/False Time Ratio'])
num_results.pivot('Number of Values', 'Proportion of Duplicate Values', 'Cache True/False Time Ratio')
```
@mroeschke do I understand that table correctly to interpret that also for the "all duplicate" case there is almost no speed-up ?
@jorisvandenbossche Correct (with maybe a slight speedup at ~100k duplicates), but keep in mind this is only for the case where the user is parsing ISO date strings. 

However, if a user is parsing a ISO date strings with tz offset (e.g `'1901-01-01 00:00:00-08:00'`) for example, the cache really helps (potentially ~2x speedup with just half duplicates):

```
Proportion of Duplicate Values       0.0       0.5       1.0
Number of Values                                            
1000.0                          1.013743  0.568563  0.054249
10000.0                         1.005625  0.518776  0.014114
100000.0                        1.008731  0.526137  0.010253
```

Bottom line, it appears that caching may improve performance depending on what the user is trying to parse, and the fact that it may introduce a slowdown is good reason to set `cache=False`. I can enhance the documentation to inform when `cache=True` should be used. 
>  can enhance the documentation to inform when cache=True should be used.

It possible we want a ``cache='infer'`` option (maybe as the default), where we do a small bit of introspection (e.g. parse first 1000 values or something) and 'guess' whether we should be using the cache things. But can do that in the future PR. IOW the cases like above (https://github.com/pandas-dev/pandas/pull/17077#issuecomment-338304458) would be really helped by-default then, but not sacrifice other cases.
I changed/updated the following:

1) Changed `cache=False` as the default after our prior discussion. Can change to 'infer' in a future PR after doing some inspection of what is being parsed.

2)  Created 2 functions outside of `to_datetime`, `_maybe_cache` which returns a cache array and `_convert_and_box_cache` which applies the cache to the data and boxes the result. 

`_maybe_cache` utilizes `_convert_listlike` for datetime conversion which is defined in `to_datetime` so I had to pass the function into `_maybe_cache`. Alternatively I could also move `_convert_listlike` out of `to_datetime`. Not sure which design is preferred. 

3) Skipped a test in `TestTimeConversionFormats.test_datetime_bool` when `cache=True` due to #18111
Adjusted the imports, fixed the doc comment, and moved the whatsnew note to v0.22.0. 

In a new issue we can highlight that `cache='infer'` should be added and maybe the cache conversion logic can be simplified. 
Added asvs with cache True/False matrix, expanded docs of helper functions, and adjusted decorator.
thanks @mroeschke nice PR!

look forward to ``cache='infer'`` !

pls open an issue for this.