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 freezed). 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.