NAME Data::DDSketch::Shared - shared-memory DDSketch relative-error quantile sketch SYNOPSIS use Data::DDSketch::Shared; # 1% relative-error quantiles my $dd = Data::DDSketch::Shared->new(undef, 0.01); $dd->add($_) for @latencies; # feed values (e.g. request latencies) my $p50 = $dd->quantile(0.50); # median, within 1% of the true value my $p99 = $dd->quantile(0.99); # tail latency my $max = $dd->max; # exact min/max are tracked too # share the sketch across processes via a backing file my $shared = Data::DDSketch::Shared->new("/tmp/latency.dd", 0.01); # freeze and ship: query it read-only (lock-free) on other machines $shared->freeze; my $ro = Data::DDSketch::Shared->new_readonly("/tmp/latency.dd"); $ro->quantile(0.99); DESCRIPTION A DDSketch in shared memory: it estimates any quantile of a stream of numbers to within a configured relative accuracy "alpha" (default 1%), in a fixed amount of memory, no matter how many values are added. Unlike a fixed histogram, the guarantee is relative: the returned value for any quantile is within a factor "alpha" of the true value, whether that quantile is a microsecond or an hour -- which is what you want for latencies and other long-tailed, wide-dynamic-range data. Each value "v" falls into a logarithmic bucket keyed by "ceil(log_gamma(|v|))", with "gamma = (1 + alpha) / (1 - alpha)"; every value in a bucket is within "alpha" of the bucket's representative value. Positive and negative magnitudes use separate bucket stores and exact zeros a dedicated counter, so the full real line is covered. The sketch also tracks the exact count, minimum, and maximum, plus a running sum, giving mean and true extremes for free. Because the buckets live in a shared mapping, several processes feed one sketch: any process that opens the same backing file, inherits the anonymous mapping across "fork", or reopens a passed memfd contributes to and reads the same distribution. A write-preferring futex rwlock with dead-process recovery guards mutation. Linux-only. Requires 64-bit Perl. Memory is bounded at "num_buckets" counters per sign (default 2048): the buckets form a fixed window centred on value 1, and values whose magnitude falls outside the window collapse into the nearest extreme bucket. With the defaults the window spans roughly 1.3e-9 to 7.6e8, so ordinary data never reaches the edges; raise "num_buckets" for a wider exact range. Two sketches created with the same "alpha" and "num_buckets" can be merged. METHODS Constructors my $dd = Data::DDSketch::Shared->new($path, $alpha, $num_buckets, $mode); my $dd = Data::DDSketch::Shared->new(undef, 0.01); # anonymous, 2048 buckets my $dd = Data::DDSketch::Shared->new_memfd($name, $alpha, $num_buckets); my $dd = Data::DDSketch::Shared->new_from_fd($fd); my $ro = Data::DDSketch::Shared->new_readonly($path); # frozen file, read-only $alpha is the relative accuracy (default 0.01 = 1%; between 1e-6 and 0.5). $num_buckets is the number of counters per sign (default 2048; between 8 and 2^24) and sets both the memory use ("2 * num_buckets * 8" bytes plus a fixed header) and the exact value range. "new" and "new_memfd" croak on an out-of-range $alpha or $num_buckets. When reopening an existing file or memfd the stored geometry wins and the caller's arguments do not resize it -- but they are still range-checked, so an out-of-range value croaks. An optional file mode may be passed as the last argument to "new" (e.g. 0660) for cross-user sharing; it defaults to 0600 (owner-only). "new_readonly" opens a frozen file read-only for lock-free querying (see "FROZEN (READ-ONLY) MODE"). Feeding values my $n = $dd->add($value); # add one value; returns the new total count $dd->insert($value); # alias for add $dd->add_many(\@values); # add a batch under a single write lock $dd->clear; # empty the sketch "add" adds one finite number (croaks on "NaN" or infinity) and returns the running total count. "add_many" adds an array reference of numbers under one write lock, validating them all first. Negative values and zero are supported. Querying my $v = $dd->quantile($q); # value at quantile $q in 0 .. 1 (undef if empty) my $med = $dd->median; # quantile(0.5) $dd->min; $dd->max; # exact smallest / largest value (undef if empty) $dd->mean; # running mean (undef if empty) $dd->sum; $dd->count; # running sum and exact number of values $dd->zero_count; # how many exact-zero values were added "quantile" returns the estimated value at quantile $q (e.g. 0.99 for the 99th percentile), guaranteed within relative error "alpha" of the true value; it returns "undef" for an empty sketch and croaks if $q is outside "[0, 1]". "min", "max", and "count" are exact (tracked separately from the buckets); "sum" and "mean" are double-precision running values subject to floating-point rounding. Merging and introspection $dd->merge($other); # fold another sketch's values in (same alpha + num_buckets) $dd->alpha; $dd->gamma; $dd->num_buckets; $dd->stats; # { alpha, gamma, num_buckets, count, zero_count, sum, min, max, mean, ops, mmap_size, frozen, readonly } "merge" requires $other to have the same "alpha" and "num_buckets" and croaks otherwise; afterwards this sketch represents the combined distribution. Lifecycle $dd->path; $dd->memfd; $dd->sync; $dd->unlink; "sync" flushes the mapping to its backing store (a no-op for anonymous and memfd sketches); "unlink" removes the backing file (also callable as "Class->unlink($path)") and croaks if the removal fails -- except when the file is already gone, which is what you asked for; it is likewise a no-op when there is no backing file (anonymous or memfd); "path" returns the backing path ("undef" for anonymous, memfd, or fd-reopened sketches) and "memfd" the backing descriptor. ACCURACY For any quantile, the returned value "e" and the true value "v" satisfy "|e - v| <= alpha * |v|" -- a relative guarantee that holds across the whole range, so the 99.9th percentile of a heavy tail is as accurate (in relative terms) as the median. This is the property a fixed-bucket histogram lacks. The count, minimum, and maximum are exact; the sum and mean are double-precision running values subject to floating-point rounding. The only approximation is the per-quantile value, and only for magnitudes inside the representable window; magnitudes outside it collapse into the extreme bucket and lose accuracy. SHARING ACROSS PROCESSES The sketch lives in a shared mapping, shared the same three ways as the rest of the family: a backing file, an anonymous mapping inherited across "fork", or a memfd passed to an unrelated process and reopened with new_from_fd($fd). The descriptor you pass is duplicated ("F_DUPFD_CLOEXEC"), so it stays yours to close and closing it does not disturb the handle. Every process's "add" feeds the one shared sketch, so a fleet of workers can each measure part of a workload into a single distribution. FROZEN (READ-ONLY) MODE A file-backed sketch can be frozen and then shipped to other machines, where consumers open it read-only and query it with no locking at all. # producer: build, freeze, ship the file my $dd = Data::DDSketch::Shared->new("/tmp/latency.dd", 0.01); $dd->add($_) for @known_latencies; $dd->freeze; # seal: now immutable, and $dd itself is read-only # ... copy /tmp/latency.dd to another host ... # consumer (any process, same architecture): read-only, lock-free my $ro = Data::DDSketch::Shared->new_readonly("/tmp/latency.dd"); $ro->quantile(0.99); $ro->count; "freeze" takes the write lock, marks the sketch permanently immutable (there is no unfreeze -- rebuild the file to change it), and flushes the seal to disk. A frozen sketch rejects every mutator ("add", "add_many", "merge", "clear") with a croak, and a read-write reopen ("new($path, ...)") of a sealed file is refused -- so a shipped artifact can never be silently mutated out from under its readers. That protection is enforced by the reader: the seal is a header flag that 0.01 and earlier do not know about, and the on-disk format version is deliberately unchanged so those releases can still open files written here. A pre-0.02 build therefore opens a sealed file read-write and can modify it, so keep producers and consumers on 0.02 or later if you rely on the seal. "freeze" itself is not idempotent: the handle that seals the file becomes a read-only view of it, so calling "freeze" on that handle again croaks. new_readonly($path) maps the file "O_RDONLY" / "PROT_READ" and requires it to be frozen (it croaks on a file that was never "freeze"d). Because a sealed sketch's buckets and geometry are immutable, "quantile", "min", "max", "mean", "sum", "count", "zero_count", and "stats" read them directly, taking no reader lock -- the mapping is never written, so a read-only view works from a read-only file descriptor or a read-only filesystem, and any number of processes can share one "PROT_READ" mapping. "frozen" and "readonly" report the two states. Portability. The on-disk format is native binary (native-endian 64-bit words), so a frozen file may be copied only between machines of the same architecture; a wrong-endian file is rejected at open by the magic check. Copy the file to each consumer -- do not share one file over a network filesystem: the lock is a Linux futex (process-local to one kernel), and the "no live writer" contract assumes a static copy. Linux-only; 64-bit Perl. SECURITY Backing files are created with mode 0600 (owner-only) by default; pass an explicit octal mode (e.g. 0660) as the last argument to "new" for cross-user sharing. The file is opened with "O_NOFOLLOW" and "O_EXCL", and the header is validated on attach. Any process granted write access is trusted not to corrupt the mapping. CRASH SAFETY Mutation is guarded by a futex-based write-preferring rwlock with PID-encoded ownership and dead-owner recovery. Each "add" is a short bounded update, but recovery restores locking only -- it performs no state repair. "add" commits the scalar aggregates (count, sum, min, max) before the bucket counter, so a crash in that window leaves "count" ahead of the buckets and quantile(1.0) can return "undef" on a non-empty sketch until the next "add" completes. Limitation: PID reuse is not detected (very unlikely in practice). Reader-slot exhaustion (slotless readers): dead-process recovery attributes a crashed lock holder's contribution through its reader-slot. The slot table holds 1024 entries (one per concurrent reader process). If more than that many reader processes share one mapping at once, a reader that cannot claim a slot proceeds "slotless" -- it still takes the read lock but leaves no per-process record. If such a slotless reader is then killed while holding the read lock, its share of the lock cannot be attributed to a dead process, so writer recovery cannot reclaim it and writers may block until the mapping is recreated. Reaching this needs more than 1024 concurrent reader processes on one mapping plus a crash in the brief read-lock window; the dead-process slot reclaim keeps the table from filling with stale entries, so in practice it is very unlikely. Those preconditions cover the live-process route only. The count lives in the mapping and "new" validates the geometry, not this transient value, so a backing file damaged at rest -- bit rot, a partial copy, or a process that scribbled on the mapping -- can present a non-zero slotless count and block every writer the same way, with none of the above. If writers hang on a file no live reader is using, recreate it. An interrupted create is recovered too. A creator killed after the backing file is sized but before its header is committed leaves a full-size, all-zero file. "new" re-initializes such a file automatically, but only when it is exactly the size the requested geometry needs, is owned by your effective uid, and is still entirely zero -- a file holding data is never re-initialized. If the creator got as far as writing part of the header, the file cannot be told apart from a corrupt one and "new" croaks with "incomplete DDSketch file left by an interrupted create; remove it and retry". A file left behind by an interrupted create never held data, so removing it is safe -- but a file whose header was corrupted after the fact reaches the same croak, so confirm it is an abandoned create before deleting anything you care about. Disk space. The backing file is created sparse: "new" sizes it, but blocks are allocated only as you write, so a large sketch costs almost nothing on disk until it is used. The cost of that is a late failure, and how it reaches you depends on the filesystem. Where blocks are allocated at fault time -- tmpfs, so "/dev/shm" and many "/tmp" mounts -- a write to a page that cannot be backed raises "SIGBUS" and kills the process, because an "mmap" store has no way to report "ENOSPC". Where allocation is delayed to writeback (ext4, xfs), the store lands in page cache and the failure appears later: the write is lost, and "sync" is what reports it, croaking with the underlying error. Keep the filesystem sized for the sketch you asked for, and call "sync" when you need to know your writes reached disk. SEE ALSO Data::Histogram::Shared (fixed-bucket histogram), the DDSketch paper (Masson, Rim, Lee, 2019), and the rest of the "Data::*::Shared" family. AUTHOR vividsnow LICENSE This is free software; you can redistribute it and/or modify it under the same terms as Perl itself.