NAME Data::Histogram::Shared - shared-memory HdrHistogram for Linux SYNOPSIS use Data::Histogram::Shared; # track values in [1, 3_600_000_000] with 3 significant figures, anonymous my $h = Data::Histogram::Shared->new(undef, 1, 3_600_000_000, 3); $h->record(120); # record the value 120 once $h->record(2500, 4); # record the value 2500 four times $h->value_at_percentile(50); # median (50th percentile) $h->percentile(99); # 99th percentile (alias) $h->min; $h->max; $h->mean; # min / max / arithmetic mean $h->total_count; # number of recorded values # bulk record in a single lock acquisition $h->record_many([ 100, 250, 250, 900, 1500 ]); # merge another histogram of identical geometry (cellwise add) my $other = Data::Histogram::Shared->new(undef, 1, 3_600_000_000, 3); $other->record_many([ 50, 75, 80 ]); $h->merge($other); # share across processes via a backing file my $shared = Data::Histogram::Shared->new("/tmp/latency.hdr", 1, 3_600_000_000, 3); # freeze and ship: query it read-only (lock-free) on other machines $shared->freeze; my $ro = Data::Histogram::Shared->new_readonly("/tmp/latency.hdr"); $ro->value_at_percentile(99); DESCRIPTION A High Dynamic Range histogram (HdrHistogram) in shared memory: a fixed-size structure for recording integer values over a wide range and querying percentiles, minimum, maximum and mean. It is suited to latency measurements that span many orders of magnitude (microseconds to seconds). You construct the histogram with the "lowest" and "highest" values it must track and the number of "sig_figs" (significant figures) of precision you require. Values are bucketed logarithmically -- one bucket per power of two of magnitude -- and linearly within each bucket. Values in the same sub-bucket are equivalent. Bucket widths are at most "1 / 10**sig_figs" of the value, except where the minimum width "2**floor(log2($lowest))" dominates. Memory is proportional to the dynamic range and the precision, not to the number of values recorded: you can record billions of samples into a fixed array of counters. The histogram stores integer values only. To record floating-point quantities, scale them to integers yourself before recording -- for example, record a latency in microseconds (or nanoseconds) rather than fractional seconds, and divide the reported percentiles back down. Because the counts array lives in a shared mapping, several processes share one histogram: any process that opens the same backing file, inherits the anonymous mapping across "fork", or reopens a passed memfd, sees the others' recordings and contributes its own. A write-preferring futex rwlock with dead-process recovery guards mutation, so many processes may "record" and query concurrently. Two histograms of identical geometry (same "lowest", "highest" and "sig_figs") can be combined with "merge" (cellwise add of their counts arrays), which yields a histogram whose distribution is the union of the two input streams. Linux-only. Requires 64-bit Perl. METHODS Constructors my $h = Data::Histogram::Shared->new($path, $lowest, $highest, $sig_figs, $mode); my $h = Data::Histogram::Shared->new(undef, 1, 3_600_000_000, 3); # defaults my $h = Data::Histogram::Shared->new_memfd($name, $lowest, $highest, $sig_figs); my $h = Data::Histogram::Shared->new_from_fd($fd); my $ro = Data::Histogram::Shared->new_readonly($path); # frozen file, read-only $path is the backing file ("undef" or omitted for an anonymous mapping). $lowest sets the minimum resolution and must be ">= 1" (default 1). $highest is the highest value that can be tracked and must be ">= 2 * $lowest" (default "3_600_000_000", i.e. one hour in microseconds). $sig_figs is the number of significant figures of precision and must be in the range 1..5 (default 3). "new" and "new_memfd" croak if any argument is out of range. $mode sets the permission bits used when "new" creates the backing file (applied exactly via "fchmod", so umask does not narrow it); the default is 0600, owner-only. Pass e.g. 0660 to opt in to sharing the file with other users in the group. It applies only at creation: reopening an existing file does not change its permissions, and the argument is ignored for anonymous histograms ($path undef or omitted). "new_memfd" and "new_from_fd" do not take a mode. $lowest additionally must satisfy "floor(log2($lowest)) + ceil(log2(2 * 10**$sig_figs)) - 1 <= 61" (a histogram-geometry limit); in practice this only rejects a very large $lowest -- above roughly "2**51" at the default "sig_figs = 3". Such a histogram croaks at construction. From these the histogram derives its bucket geometry: a unit magnitude of "floor(log2($lowest))", "2 * 10**sig_figs" sub-buckets per power of two (rounded up to a power of two), and as many buckets as are needed to cover $highest. When reopening an existing file or memfd, the stored geometry wins and the caller's $lowest/$highest/$sig_figs arguments are ignored. "new_memfd" creates a Linux memfd (transferable via its "memfd" descriptor); "new_from_fd" reopens one in another process. The descriptor you pass is duplicated ("F_DUPFD_CLOEXEC"), so it stays yours to close and closing it does not disturb the handle. "new_readonly" opens a frozen file read-only for lock-free querying (see "FROZEN (READ-ONLY) MODE"). "new" and "new_memfd" create a new segment sparse: pages are allocated as they are first written, and once the filesystem is full, a "record" into a page not yet allocated dies with "SIGBUS". Set "DATA_HISTOGRAM_SHARED_SPARSE=0" to reserve the whole segment at creation, so a full filesystem makes them croak instead; on tmpfs and memfd that commits the segment's memory at once, and a memory cgroup too small for it gets an OOM kill rather than a croak. Recording values my $count = $h->record($value); # record once; returns new total_count my $count = $h->record($value, $n); # record $n times; returns new total_count my $added = $h->record_many(\@values); # record each once; returns how many $h->reset; # clear every count back to empty "record" adds $n (default 1) occurrences of the integer $value to the histogram and returns the new "total_count". $value must be in the range "0 .. $highest": a negative value croaks, and a value above $highest croaks ("value ... exceeds highest_trackable_value"). Values strictly below $lowest are recorded at the minimum bucket width. $n is an unsigned integer. "record_many" takes an array reference and records each element once under a single write lock, returning the number recorded. Every element is range-checked before the lock is taken, so an out-of-range element croaks without recording any of the batch (and without holding the lock). Percentiles return the highest equivalent value of the selected bucket. They can exceed the largest recorded value or the configured $highest when either lies inside a bucket. Querying my $v = $h->value_at_percentile($p); # value at the $p-th percentile (0..100) my $v = $h->percentile($p); # alias for value_at_percentile my $c = $h->count_at_value($value); # count recorded in $value's bucket my $lo = $h->min; # lowest recorded value (0 if empty) my $hi = $h->max; # highest recorded value (0 if empty) my $mu = $h->mean; # arithmetic mean (0 if empty) my $n = $h->total_count; # number of recorded values my $n = $h->count; # alias for total_count "value_at_percentile" returns the value below which $p percent of the recorded values lie: the highest equivalent value of the bucket at that percentile, so it never underestimates. $p is a percentile in the range 0..100 (not a 0..1 quantile). "percentile" is a short alias. An empty histogram returns 0 for any percentile. "count_at_value" returns the number of values recorded in the bucket that $value falls into (so values equivalent to $value are counted together). $value is range-checked like "record": a negative value, or a value above $highest, croaks. "min" and "max" are the exact lowest and highest values ever recorded (each 0 when nothing has been recorded). "mean" is the arithmetic mean, computed from each bucket's median-equivalent value. "total_count" (aliased "count") is the number of recorded values -- the sum of all the per-bucket counts. Merging $h->merge($other); Folds $other's counts array into $h by cellwise addition, so $h then represents the combined distribution of both histograms; $h's "total_count", "min" and "max" are updated to span both. Both histograms must have identical geometry -- the same "lowest", "highest" and "sig_figs" ("merge" croaks on a mismatch). $other is read under its own lock into a private snapshot first, so merging is deadlock-free even if two processes merge each other concurrently; $other is not modified. Counts that would overflow a 64-bit cell saturate at the maximum value. Introspection and lifecycle $h->lowest; $h->highest; $h->sig_figs; $h->counts_len; $h->stats; $h->path; $h->memfd; $h->sync; $h->unlink; # or Class->unlink($path) "lowest", "highest" and "sig_figs" return the configured geometry; "counts_len" is the number of 64-bit counter cells. "sync" flushes the mapping to its backing store (a no-op for anonymous and memfd histograms, which have none); "unlink" removes the backing file (also callable as "Class->unlink($path)"); "path" returns the backing path ("undef" for anonymous, memfd, or fd-reopened histograms) and "memfd" the backing descriptor -- the memfd of a "new_memfd" histogram or the dup'd fd of a "new_from_fd" histogram, and -1 for file-backed or anonymous histograms. STATS stats() returns a hashref describing the histogram: * "lowest" -- the lowest trackable value. * "highest" -- the highest trackable value. * "sig_figs" -- the significant figures of precision. * "count" -- the number of recorded values ("total_count"). * "min" -- the lowest recorded value (0 if empty). * "max" -- the highest recorded value (0 if empty). * "mean" -- the arithmetic mean of the recorded values (0.0 if empty). * "counts_len" -- the number of 64-bit counter cells. * "bucket_count" -- the number of logarithmic buckets. * "sub_bucket_count" -- the number of linear sub-buckets per bucket. * "ops" -- running count of mutating operations ("record", "record_many", "merge", "reset"). * "mmap_size" -- bytes of the shared mapping. * "frozen" -- 1 if the histogram has been sealed by "freeze" (immutable), else 0. * "readonly" -- 1 if this handle is a read-only view (from "new_readonly", or the handle that called "freeze"), else 0. SHARING ACROSS PROCESSES Processes sharing a mutable histogram must be in the same PID namespace; lock recovery uses local process IDs. The histogram lives in a shared mapping, shared the same three ways as the rest of the family: a backing file (every process calls "new($path, ...)" on the same path with matching geometry), an anonymous mapping inherited across "fork", or a memfd whose descriptor is passed to an unrelated process (over a UNIX socket via "SCM_RIGHTS", or via "/proc/$pid/fd/$n") and reopened with new_from_fd($fd). Because the mapping is shared, every process records into and queries the same counts array, so the distribution reflects the combined stream all of them have recorded. # producer and consumer share one histogram with no coordination my $h = Data::Histogram::Shared->new(undef, 1, 1_000_000, 3); # before fork unless (fork) { $h->record(500) for 1 .. 10; exit } wait; print $h->value_at_percentile(50), "\n"; # the child's recordings FROZEN (READ-ONLY) MODE A file-backed histogram 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 $h = Data::Histogram::Shared->new("/tmp/latency.hdr", 1, 3_600_000_000, 3); $h->record_many(\@samples); $h->freeze; # seal: now immutable, and $h itself is read-only # ... copy /tmp/latency.hdr to another host ... # consumer (any process, same architecture): read-only, lock-free my $ro = Data::Histogram::Shared->new_readonly("/tmp/latency.hdr"); $ro->value_at_percentile(99) for @queries; "freeze" takes the write lock, marks the histogram permanently immutable (there is no unfreeze -- rebuild the file to change it), and flushes the seal to disk. A frozen histogram rejects every mutator ("record", "record_many", "merge", "reset") 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. 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 histogram's counts and geometry are immutable, "value_at_percentile", "percentile", "count_at_value", "min", "max", "mean", "total_count", "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, so only the creating user can open and attach them. To share a backing file across users, pass an explicit octal file mode such as 0660 as the last argument to "new"; the mode is applied when the file is created, and when a file left behind by an interrupted create is re-initialized (see "CRASH SAFETY"); a file already in use keeps its own permissions. The file is opened with "O_NOFOLLOW", so a symlink planted at the path is refused, and created with "O_EXCL"; the on-disk header is validated when the file is attached. Any process you grant write access to a shared mapping is trusted not to corrupt its contents while other processes are using it. CRASH SAFETY Mutation is guarded by a futex-based write-preferring rwlock with PID-encoded ownership; if a holder dies, the next contender detects the dead owner and recovers. Recovery releases locks; it does not roll back an interrupted mutation. A killed "record", "merge" or "reset" can leave partially applied counts or extrema, skewing percentiles and the mean until the next "reset". 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 handle that takes a lock). If more than that many handles 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 handles 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. 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 histogram 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. SEE ALSO Data::CountMinSketch::Shared, Data::HyperLogLog::Shared, Data::BloomFilter::Shared, Data::Intern::Shared, Data::SortedSet::Shared, Data::SpatialHash::Shared, 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.