Packages

Fast HyperLogLog implementation for Elixir/Erlang

Current section

Files

Jump to
hypex lib hypex.ex
Raw

lib/hypex.ex

defmodule Hypex do
use Bitwise
@moduledoc """
This module provides an Elixir implementation of HyperLogLog as described within
http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf. Current implementation
works with an underlying bitstring which acts as the registers in the algorithm.
A Hypex instance is simply a two-element Tuple, which provides a slight speed
improvement over using a struct (roughly 10% at last benchmark). This tuple
should only ever be constructed via `Hypex.new/1` otherwise you run the risk of
pattern matching errors throughout modification.
"""
# alias internal modules
alias Hypex.Util
# our hash size in bits
@hash_length 32
# the maximum uniques allowed by the hash
@max_uniques 1 <<< @hash_length
# cardinality error
@card2_err "Hypex.cardinality/1 requires a valid Hypex instance"
# merge error
@merge_err "Merging requires valid Hypex structures of the same width"
# invalid construction error
@range_err "Invalid width provided, must be 16 >= width >= 4"
# update error
@update_err "Hypex.update/2 requires a valid Hypex instance"
# define the Hypex typespec
@type hypex :: { number, bitstring }
@doc """
Create a new Hypex using a width `b` when `16 >= b >= 4`.
We determine the number of internal registers based on `b` as `m` when `m` is
equivalent to `2 ^ b`. We then initialize `m` registers with 0 bits and return
a Tuple of `{ b, registers }`.
## Examples
iex> Hypex.new(4)
{ 4, << 0, 0, 0, 0, 0, 0, 0, 0 >> }
"""
@spec new(number) :: hypex
def new(b \\ 16)
def new(b) when is_integer(b) and b <= 16 and b >= 4 do
m = (1 <<< b) * b
{ b, << 0 :: size(m) >> }
end
def new(_b) do
raise ArgumentError, message: @range_err
end
@doc """
Calculates a cardinality based upon a passed in Hypex.
We use a binary reduce function internally to make this as cheap as possible and
we use only a single pass (even if it's a little worse for memory). Corrections
are applied per the algorithm definition to account to the bit size of the hash
function.
## Examples
iex> hypex = Hypex.new(4)
iex> hypex = Hypex.update(hypex, "one")
iex> hypex = Hypex.update(hypex, "two")
iex> hypex = Hypex.update(hypex, "three")
iex> Hypex.cardinality(hypex) |> round
3
"""
@spec cardinality(hypex) :: number
def cardinality({ b, _registers } = hypex) do
m = 1 <<< b
{ helper, zeroes } = binary_reduce(hypex, { 0, 0 }, fn(int, { helper, zeroes }) ->
{ 1 / (1 <<< int) + helper, int == 0 && zeroes + 1 || zeroes }
end)
raw_estimate = a(m) * m * m * 1 / helper
apply_correction(zeroes, m, raw_estimate)
end
def cardinality(_hypex) do
raise ArgumentError, message: @card2_err
end
@doc """
Merges together many Hypex instances with the same seed.
This is done in a readable way as opposed to a performant way as it will be only
rarely called. We zip up the two input bitstrings and reduce them into a single
bitstring, taking the max bit from either and folding it into the reduction.
## Examples
iex> h1 = Hypex.new(4)
iex> h1 = Hypex.update(h1, "one")
iex> h1 = Hypex.update(h1, "two")
iex> h2 = Hypex.new(4)
iex> h2 = Hypex.update(h2, "three")
iex> h3 = Hypex.merge([h1, h2])
iex> Hypex.cardinality(h3) |> round
3
"""
@spec merge([ hypex ]) :: hypex
def merge([ { b, _registers } | _ ] = hypices) do
unless Enum.all?(hypices, &(match?({ ^b, _ }, &1))) do
raise ArgumentError, message: @merge_err
end
registers = Enum.map(hypices, fn({ _b, registers }) ->
:erlang.bitstring_to_list(registers)
end)
m_reg = registers |> Util.ziplist |> Enum.reduce(<<>>, fn(bits, register) ->
register <> << :lists.max(bits) >>
end)
{ b, m_reg }
end
def merge(_hypices) do
raise ArgumentError, message: @merge_err
end
@doc """
Merges together two Hypex instances with the same seed.
Internally this function just wraps the two instances in a list and passes them
throguh to `merge/1`.
"""
@spec merge(hypex, hypex) :: hypex
def merge(h1, h2),
do: merge([ h1, h2 ])
@doc """
Updates a Hypex instance with a value.
Internally `:erlang.phash2/2` is used as a 32-bit hash function due to it being
both readily available and relatively fast. Everything here is done via pattern
matching to achieve fast speeds.
The main performance hit of this function comes when there's a need to modify
a bit inside the bitstring, so we protect against doing this unnecessarily by
pre-determining whether the modification will be a no-op.
## Examples
iex> 4 |> Hypex.new |> Hypex.update("one")
{ 4, << 0, 0, 0, 0, 0, 0, 0, 2 >> }
"""
@spec update(hypex, any) :: hypex
def update({ b, registers } = hypex, value) do
<< idx :: size(b), rest :: bitstring >> = << :erlang.phash2(value, @max_uniques) :: size(@hash_length) >>
head_length = idx * b
<< head :: bitstring-size(head_length), current_value :: size(b), tail :: bitstring >> = registers
case max(current_value, count_leading_zeros(rest)) do
^current_value ->
hypex
new_value ->
{ b, << head :: bitstring, new_value :: size(b), tail :: bitstring >> }
end
end
def update(_hypex, _value) do
raise ArgumentError, message: @update_err
end
# Defines the value of `a` per the algorithm definition, with special casing
# for when `m` is any of 16, 32 or 64. Anything higher than 128 is calculated
# in a general way using the algorithm implementation.
defp a(16), do: 0.673
defp a(32), do: 0.697
defp a(64), do: 0.709
defp a(m) when m >= 128, do: 0.7213 / (1 + 1.079 / m)
# Applies a correction to the raw esimation based on the size of the raw estimate.
# The three function heads apply corrections for small/medium/large ranges (top-down).
defp apply_correction(zero_count, m, raw_estimate) when raw_estimate <= 5 * m / 2 do
case zero_count do
0 -> raw_estimate
z -> m * :math.log(m / z)
end
end
defp apply_correction(_zero_count, _m, raw_estimate) when raw_estimate <= @max_uniques / 30 do
raw_estimate
end
defp apply_correction(_zero_count, _m, raw_estimate) do
-@max_uniques * :math.log(1 - raw_estimate / @max_uniques)
end
# A small binary reducer to avoid having to convert a bitstring to a list in
# order to iterate effectively. This shaves off about half a millisecond of
# execution time when operating on a `b = 16` Hypex.
defp binary_reduce({ b, registers }, acc, fun) do
binary_reduce(b, registers, acc, fun)
end
defp binary_reduce(_b, <<>>, acc, _fun), do: acc
defp binary_reduce(b, registers, acc, fun) do
<< head :: size(b), rest :: bitstring >> = registers
binary_reduce(b, rest, fun.(head, acc), fun)
end
# Counts the leading zeros in a bitstring by walking the entire bitstring. This
# sounds horribly expensive but typically a non-zero is hit within a few calls.
defp count_leading_zeros(registers, count \\ 1)
defp count_leading_zeros(<< 0 :: size(1), rest :: bitstring >>, count),
do: count_leading_zeros(rest, count + 1)
defp count_leading_zeros(_registers, count), do: count
end