Mix.install([
{:ex_data_sketch, "~> 0.10"}
])Introduction
ExDataSketch.CMS estimates how many times each item has occurred in a
stream -- "how many requests hit this endpoint," "how many times was this
error logged" -- in fixed memory, without storing a counter per distinct
item. CMS estimates are always over-estimates, never under (hash
collisions can only add extra weight to a counter, never subtract), and
the amount of over-estimation shrinks as width/depth grow.
Sample data (cached locally)
cache_path = Path.join(System.tmp_dir!(), "ex_data_sketch_livebook_cache/cms_sample.bin")
events =
if File.exists?(cache_path) do
IO.puts("Loading cached sample data from #{cache_path}")
cache_path |> File.read!() |> :erlang.binary_to_term()
else
IO.puts("Generating sample data (this takes a few seconds)...")
# 2,000,000 page-view events over 10,000 distinct pages, power-law
# distributed (a few pages get most of the traffic, a long tail gets
# almost none) -- realistic for request/error/page-view frequency data,
# and the skew is exactly what makes frequency estimation interesting.
events =
for _ <- 1..2_000_000 do
rank = trunc(:math.pow(:rand.uniform(), 2) * 9_999) + 1
"page_#{rank}"
end
File.mkdir_p!(Path.dirname(cache_path))
File.write!(cache_path, :erlang.term_to_binary(events))
events
end
true_counts = Enum.frequencies(events)
IO.puts("#{length(events)} events across #{map_size(true_counts)} distinct pages")Basic usage
alias ExDataSketch.CMS
sketch = CMS.new() |> CMS.update("page_1") |> CMS.update("page_1") |> CMS.update("page_2")
CMS.estimate(sketch, "page_1")update/3 also accepts an explicit increment (useful when you're
replaying pre-aggregated counts rather than raw events):
CMS.new() |> CMS.update("page_1", 5) |> CMS.estimate("page_1")For a batch like our sample data, update_many/2 is far more efficient
than calling update/2 in a loop -- but the backend matters far more
than the batching does. ExDataSketch.CMS.new/1 defaults to the Pure
Elixir backend regardless of whether the Rust NIF is installed -- you
always have to opt in explicitly with backend: ExDataSketch.Backend.Rust.
Measured on this tutorial's hardware, update_many/2 over 500,000 events
(width: 2048, depth: 5) took 26.7s on Pure vs 39ms on the Rust
NIF backend -- a ~679x speedup. Every CMS.new/1 call below passes
backend: ExDataSketch.Backend.Rust for exactly this reason; drop it (or
fall back to Pure when ExDataSketch.Backend.Rust.available?/0 is
false, e.g. no precompiled NIF for your platform) and these cells will
still work, just dramatically slower on the full 2,000,000-event sample.
[{top_page, top_true_count} | _] = Enum.sort_by(true_counts, fn {_, c} -> -c end)
IO.puts("#{top_page}: true count=#{top_true_count}")
sketch = CMS.new(backend: ExDataSketch.Backend.Rust, width: 2048, depth: 5) |> CMS.update_many(events)
estimate = CMS.estimate(sketch, top_page)
IO.puts("CMS estimate=#{estimate}")
IO.puts("Sketch size: #{CMS.size_bytes(sketch)} bytes (fixed, regardless of distinct pages)")Accuracy: over-estimation only, and where it shows up
CMS never under-counts. Check every distinct page and confirm the
estimate is always >= the true count, and see where the error actually
lands (the least-popular pages, which get squeezed by hash collisions
with the many popular ones sharing the same width x depth grid):
results =
for {page, true_count} <- true_counts do
estimate = CMS.estimate(sketch, page)
{page, true_count, estimate, estimate - true_count}
end
never_undercounts? = Enum.all?(results, fn {_, _, _, diff} -> diff >= 0 end)
IO.puts("Every estimate >= true count: #{never_undercounts?}")
worst = Enum.max_by(results, fn {_, _, _, diff} -> diff end)
IO.puts("Worst over-estimate: #{inspect(worst)}")
avg_error =
results |> Enum.map(fn {_, true_c, est, _} -> (est - true_c) / max(true_c, 1) end) |> Enum.sum()
avg_error = avg_error / map_size(true_counts) * 100
IO.puts("Average relative over-estimate: #{Float.round(avg_error, 2)}%")Both numbers can look alarming at first glance -- a worst-case estimate in
the tens of thousands for a page with a true count in the hundreds, and
an average relative error well over 100%. Neither indicates a bug: the
worst result is the extreme of ~10,000 independent queries, each with
an independent ~e^-depth chance of exceeding the typical error bound,
so finding one outlier among that many is expected, not anomalous. And
the average is dominated by tail pages whose true count is tiny (1-50):
a few hundred counts of fixed hash-collision noise (~total_events / width per row) is negligible against page_1's true count of 20,207,
but enormous as a percentage of a true count of 5. The "Sizing" section
below shows this shrinking directly as width/depth grow.
Sizing: width/depth trade-off
width controls per-row collision rate, depth controls how many
independent rows vote (taking the minimum across rows is what caps the
over-estimation) -- more of either means more memory and less error.
Each {width, depth} combination independently rebuilds a sketch from
all 2,000,000 events. Each build already uses the Rust backend (see the
note in "Basic usage" -- that's the ~679x speedup that makes rebuilding
from scratch three times even feasible in a tutorial); Task.async/1
runs the three builds concurrently on top of that for a further, smaller
win:
{top_page, top_true} = Enum.max_by(true_counts, fn {_, c} -> c end)
tasks =
for {width, depth} <- [{256, 3}, {1024, 5}, {4096, 7}] do
Task.async(fn ->
s = CMS.new(backend: ExDataSketch.Backend.Rust, width: width, depth: depth) |> CMS.update_many(events)
{width, depth, CMS.size_bytes(s), CMS.estimate(s, top_page)}
end)
end
for {width, depth, size_bytes, est} <- Task.await_many(tasks, :infinity) do
IO.puts(
"width=#{width}, depth=#{depth} (#{size_bytes} bytes): " <>
"top page true=#{top_true}, estimate=#{est}"
)
endMerging
Simulating two independent workers means two independent
update_many/2 calls, each over a million events -- exactly the kind of
work Task.async/1 is for:
half = div(length(events), 2)
{first_half, second_half} = Enum.split(events, half)
[worker_a, worker_b] =
[first_half, second_half]
|> Enum.map(fn chunk ->
Task.async(fn -> CMS.new(backend: ExDataSketch.Backend.Rust, width: 2048, depth: 7) |> CMS.update_many(chunk) end)
end)
|> Task.await_many(:infinity)
merged = CMS.merge(worker_a, worker_b)
{top_page, top_true} = Enum.max_by(true_counts, fn {_, c} -> c end)
IO.puts("Merged estimate for #{top_page}: #{CMS.estimate(merged, top_page)} (true: #{top_true})")Serialization
binary = CMS.serialize(sketch)
{:ok, restored} = CMS.deserialize(binary)
IO.puts("Round-tripped estimate: #{CMS.estimate(restored, top_page)}")When CMS isn't the right tool
CMS answers "what's the count for this specific item?" -- it has no way
to tell you which items are the heavy hitters without probing every
candidate individually. If you need "give me the top-K items," use
ExDataSketch.FrequentItems or ExDataSketch.MisraGries instead -- see
livebooks/sketches/frequent_items.livemd and
livebooks/sketches/misra_gries.livemd.
See also
ExDataSketch.CMSmodule documentation -- full API reference.guides/apache_interop.md-- CMS is one of the families with Apache DataSketches binary interop (serialize_datasketches/1).