Mix.install([
  {:ex_data_sketch, "~> 0.10"}
],
config: [
    ex_data_sketch: [
      backend: ExDataSketch.Backend.Rust,
      integrations: [opentelemetry: false]
    ]
  ])

Introduction

ExDataSketch.DDSketch estimates quantiles like ExDataSketch.KLL, but with a different accuracy guarantee: relative error on the value, not the rank. A query for quantile q returns a value v such that the true value v' satisfies v' * (1 - alpha) <= v <= v' * (1 + alpha) -- the same relative accuracy whether you're looking at a 2ms value or a 20,000ms one. That makes it well suited to latency/telemetry data that spans several orders of magnitude, where "off by 1ms" matters a lot at the low end and not at all at the high end.

Sample data (cached locally)

cache_path = Path.join(System.tmp_dir!(), "ex_data_sketch_livebook_cache/ddsketch_sample.bin")

durations =
  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)...")

    # 1,000,000 operation durations (ms) spanning several orders of
    # magnitude: fast API calls (~1-50ms), medium DB queries (~50-500ms),
    # and rare slow batch jobs (~10,000-100,000ms). DDSketch only accepts
    # non-negative values.
    durations =
      for _ <- 1..1_000_000 do
        case :rand.uniform(1000) do
          n when n <= 900 -> :rand.uniform() * 50
          n when n <= 990 -> 50 + :rand.uniform() * 450
          _ -> 10_000 + :rand.uniform() * 90_000
        end
      end

    File.mkdir_p!(Path.dirname(cache_path))
    File.write!(cache_path, :erlang.term_to_binary(durations))
    durations
  end

sorted = Enum.sort(durations)
IO.puts("#{length(durations)} duration samples ready, spanning #{Float.round(List.first(sorted), 2)}ms to #{Float.round(List.last(sorted), 0)}ms")

Basic usage

alias ExDataSketch.DDSketch

sketch = DDSketch.new(alpha: 0.01) |> DDSketch.update_many(durations)

DDSketch.quantiles(sketch, [0.5, 0.9, 0.99, 0.999])

Relative accuracy at both ends of the scale

Compare DDSketch's error at a small quantile value against a large one -- both should be within alpha, unlike a fixed absolute-error sketch, where the large value's error would dwarf the small one's:

exact_percentile = fn p ->
  # Matches ExDataSketch.DDSketch.quantile/2's own rank convention exactly
  # (target = p * n, returns the value once cumulative count reaches that
  # target -- effectively the target-th smallest item, 1-indexed) rather
  # than an independently-chosen indexing scheme. A plain
  # `Enum.at(sorted, round(p * n))` is 0-indexed and off by one rank from
  # what DDSketch itself targets; that one-rank gap is usually
  # inconsequential, but exactly at a bucket boundary it can land the
  # "exact" reference value in the *next* bucket over from the one
  # DDSketch correctly answered for, making a correct estimate look like
  # it exceeded `alpha` when it didn't.
  rank = max(1, round(p * length(sorted)))
  Enum.at(sorted, min(rank - 1, length(sorted) - 1))
end

# p50 is in the "fast API calls" range (small values); p99.5 is in the
# "slow batch jobs" range (large values) -- three orders of magnitude apart.
for p <- [0.5, 0.995] do
  exact = exact_percentile.(p)
  estimate = DDSketch.quantile(sketch, p)
  relative_error = abs(estimate - exact) / exact * 100
  IO.puts("p#{p}: exact=#{Float.round(exact, 2)}ms, estimate=#{Float.round(estimate, 2)}ms, relative error=#{Float.round(relative_error, 2)}%")
end

Both relative errors should be at most alpha * 100 = 1%, regardless of the three-orders-of-magnitude gap between the two values -- that's the DDSketch guarantee, and it's a tight bound (achieved at a bucket's edges), not a "usually comfortably inside" one, so don't be surprised to see values sitting close to 1% rather than well under it.

Sizing: alpha trade-off

for alpha <- [0.05, 0.01, 0.005, 0.001] do
  s = DDSketch.new(alpha: alpha) |> DDSketch.update_many(durations)
  estimate = DDSketch.quantile(s, 0.99)
  exact = exact_percentile.(0.99)
  error_pct = abs(estimate - exact) / exact * 100
  IO.puts("alpha=#{alpha} (#{DDSketch.size_bytes(s)} bytes): p99 error=#{Float.round(error_pct, 2)}%")
end

count, min, max

min_value/1/max_value/1 track the raw minimum/maximum ever inserted at full precision -- independent of alpha and of the log-scale bucket machinery entirely. With 900,000 samples drawn uniformly from [0, 50) for the "fast API calls" bucket, the smallest of that many draws lands well under 0.001ms (expected order of magnitude ~50 / 900_000), so don't be surprised to see Min: 0.0ms if you round to only 3 decimal places -- it's rounding display precision, not the sketch losing the value:

IO.puts("Count: #{DDSketch.count(sketch)}")
IO.puts("Min: #{DDSketch.min_value(sketch)}ms (exact, not rounded)")
IO.puts("Max: #{Float.round(DDSketch.max_value(sketch), 0)}ms")

Merging

half = div(length(durations), 2)
{first_half, second_half} = Enum.split(durations, half)

worker_a = DDSketch.new(alpha: 0.01) |> DDSketch.update_many(first_half)
worker_b = DDSketch.new(alpha: 0.01) |> DDSketch.update_many(second_half)

merged = DDSketch.merge(worker_a, worker_b)
IO.puts("Merged p99: #{Float.round(DDSketch.quantile(merged, 0.99), 1)}ms")

Serialization

binary = DDSketch.serialize(sketch)
{:ok, restored} = DDSketch.deserialize(binary)
IO.puts("Round-tripped p50: #{Float.round(DDSketch.quantile(restored, 0.5), 2)}ms")

See also

  • ExDataSketch.DDSketch module documentation -- full API reference and the DDS1 binary layout.
  • ExDataSketch.KLL -- rank-relative accuracy instead of value-relative; see livebooks/sketches/kll.livemd for when that distinction matters.
  • ExDataSketch.REQ -- extra accuracy specifically at extreme tail quantiles; see livebooks/sketches/req.livemd.