PERF: improved performance of small multiindexes
closes #16319

When I did #15245 the goal was two fold
 - Reduce the hashtable creation time for a MultiIndex
 - Reduce the memory footprint

original impl
-------------
These are interrelated, as the original impl of a MI constructed a list of tuples for each value (the len of the tuple is the number of levels). Then we construct a PyObject based hashtable. This is pretty slow & has the side effect of blowing up memory as a list of tuples is pretty heavyweight (when you have a large one).

Indexing is simple, you simply hash a passed tuple and do a lookup in the hashtable. This is quite fast.

hashtable impl
---------------
The new implementation, instead data hashes the levels themselves and performs transforms to preserve ordering. This is much faster than the original implementation because we have common dtypes and can construct these hashes in a vectorized way. So this is fast and memory efficient

Looking up a value involves constructing the hash for a tuple. This has some overhead as for impl simplicity this is partly python code and has a few steps. This could be improved. So the actual construction of the hash of the value to be lookuped up is slower than the original impl.

Naive timing of an indexing on a new DataFrame is very misleading. ``%timeit`` gives the min time, which includes the construction cost on the first iteration, and subequent access times. However it returns the min, which is obviously not including the cached time. We actually care about the *sum* of access times. 

We *always* have to construct the hash table in order to index, so its cost is more than relevant, in fact it would dominate things.

This PR allow the original implementation for smaller MultiIndexes. I chose 10000 elmenents (times are still slightly slower for the hash-based construction by bout 1.5x, but balancing memory bloat here). It is vastly better for larger than this. (if some enterprising soul wants to draw the graph of amortized construction cost would be great!)

So we at run-time choose the engine for indexing. We can then have good smallish perf, and nice scaling on larger hash tables.


setup
```
import string
mi_large = pd.MultiIndex.from_product(
    [np.arange(1000),
     np.arange(20), list(string.ascii_letters)],
    names=['one', 'two', 'three'])
mi_med = pd.MultiIndex.from_product(
    [np.arange(1000),
     np.arange(10), list('A')],
    names=['one', 'two', 'three'])
mi_small = pd.MultiIndex.from_product(
    [np.arange(100), list('A'), list('A')],
    names=['one', 'two', 'three'])
```

These timing show index construction cost (first execution), and marginal after than (2nd) for small, med, large.

v0.19.2
```
In [2]: %time mi_small.get_loc((99, 'A'))
CPU times: user 281 µs, sys: 90 µs, total: 371 µs
Wall time: 329 µs
Out[2]: slice(99, 100, None)

In [3]: %time mi_small.get_loc((99, 'A'))
CPU times: user 117 µs, sys: 20 µs, total: 137 µs
Wall time: 125 µs
Out[3]: slice(99, 100, None)

In [4]: %time mi_med.get_loc((999, 9, 'A'))
CPU times: user 3.37 ms, sys: 918 µs, total: 4.29 ms
Wall time: 3.67 ms
Out[4]: 9999

In [5]: %time mi_med.get_loc((999, 9, 'A'))
CPU times: user 28 µs, sys: 0 ns, total: 28 µs
Wall time: 31 µs
Out[5]: 9999

In [6]: %time mi_large.get_loc((999, 19, 'E'))
CPU times: user 875 ms, sys: 67.9 ms, total: 943 ms
Wall time: 943 ms
Out[6]: 1039978

In [7]: %time mi_large.get_loc((999, 19, 'E'))
CPU times: user 31 µs, sys: 0 ns, total: 31 µs
Wall time: 33.9 µs
Out[7]: 1039978
```

PR
```
In [2]: %time mi_small.get_loc((99, 'A'))
CPU times: user 254 µs, sys: 37 µs, total: 291 µs
Wall time: 260 µs
Out[2]: slice(99, 100, None)

In [3]: %time mi_small.get_loc((99, 'A'))
CPU times: user 129 µs, sys: 17 µs, total: 146 µs
Wall time: 135 µs
Out[3]: slice(99, 100, None)

In [4]: %time mi_med.get_loc((999, 9, 'A'))
CPU times: user 3.31 ms, sys: 1.12 ms, total: 4.43 ms
Wall time: 5.75 ms
Out[4]: 9999

In [5]: %time mi_med.get_loc((999, 9, 'A'))
CPU times: user 32 µs, sys: 1 µs, total: 33 µs
Wall time: 35.8 µs
Out[5]: 9999

In [6]: %time mi_large.get_loc((999, 19, 'E'))
CPU times: user 130 ms, sys: 40.3 ms, total: 170 ms
Wall time: 171 ms
Out[6]: 1039978

In [7]: %time mi_large.get_loc((999, 19, 'E'))
CPU times: user 3.44 ms, sys: 348 µs, total: 3.79 ms
Wall time: 3.53 ms
Out[7]: 1039978
```