mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
perf_hooks: implement qrde analysis support in Histogram
Implements quantile-respectful density estimate calcuation on Histogram. Helps with tail-focused latency analysis without retaining raw samples. Baking this directly into Node.js, based on a 1 million sample, 1k bin workload, this impl is roughly 150-325x faster than performing the equivalent in Rscript. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode PR-URL: https://github.com/nodejs/node/pull/65806 Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
1 parent
1c179c99e7
commit
666edfda29
10 files changed
+1480
-41
No files matched your search
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
|
||||
const common = require('../common.js');
|
||||
const { createHistogram } = require('perf_hooks');
|
||||
|
||||
const bench = common.createBenchmark(main, {
|
||||
n: [5],
|
||||
bins: [100, 1000],
|
||||
samples: [1e6],
|
||||
unique: [100, 1000, 10000],
|
||||
dequantize: ['none', 'hdr', 'all'],
|
||||
}, {
|
||||
test: {
|
||||
n: 1,
|
||||
bins: 10,
|
||||
samples: 100,
|
||||
unique: 10,
|
||||
},
|
||||
});
|
||||
|
||||
async function main({ n, bins, samples, unique, dequantize }) {
|
||||
const histogram = createHistogram();
|
||||
const maximum = 1e12;
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const index = i % unique;
|
||||
const rank = unique === 1 ? 0 : index / (unique - 1);
|
||||
histogram.record(Math.max(1, Math.round(maximum ** rank)));
|
||||
}
|
||||
|
||||
await histogram.qrde({ bins, dequantize });
|
||||
bench.start();
|
||||
for (let i = 0; i < n; i++) {
|
||||
await histogram.qrde({ bins, dequantize });
|
||||
}
|
||||
bench.end(n);
|
||||
}
|
||||
@@ -2467,6 +2467,82 @@ Returns the values at the specified percentiles, computed in a single
|
||||
efficient pass over the histogram data. More efficient than calling
|
||||
`histogram.percentile()` multiple times.
|
||||
|
||||
### `histogram.qrde([options])`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
* `bins` {number} The number of equal-probability density bins to return.
|
||||
Must be between 1 and 1000. Cannot be used with `probabilities`.
|
||||
**Default:** `100`.
|
||||
* `probabilities` {number\[]} Custom probability boundaries. The array must
|
||||
contain between 2 and 1001 strictly increasing values, start with `0`, and
|
||||
end with `1`. Cannot be used with `bins`.
|
||||
* `dequantize` {string} Controls whether repeated bucket values are spread
|
||||
deterministically over their equivalent-value ranges. May be `'none'`,
|
||||
`'hdr'`, or `'all'`. **Default:** `'hdr'`.
|
||||
* `cache` {boolean} When `true`, retains the expanded histogram snapshot for
|
||||
reuse by subsequent calls with `cache: true`. The snapshot is invalidated
|
||||
when the histogram is modified. **Default:** `false`.
|
||||
* Returns: {Promise} Fulfills with an {Object} containing:
|
||||
* `probabilities` {Float64Array} The probability boundaries used by the
|
||||
estimate.
|
||||
* `quantiles` {Float64Array} The quantiles at the probability boundaries.
|
||||
* `densities` {Float64Array} The density within each quantile interval.
|
||||
* `count` {bigint} The number of values in the histogram snapshot.
|
||||
* `bucketCount` {number} The number of occupied HDR buckets.
|
||||
* `corrections` {number} The number of non-monotonic floating-point results
|
||||
that were clamped to the preceding quantile.
|
||||
* `dequantize` {string} The selected dequantization mode.
|
||||
|
||||
Returns a quantile-respectful density estimate based on the Harrell-Davis
|
||||
quantile estimator. By default, `bins` generates equal probability boundaries.
|
||||
The `probabilities` option can instead focus the estimate on regions such as
|
||||
p90, p99, p99.9, and p99.99. The density for interval `i` contains probability
|
||||
mass `probabilities[i + 1] - probabilities[i]`. The histogram is snapshotted
|
||||
when the method is called. Snapshot expansion and the estimate are calculated
|
||||
in the libuv thread pool. Highly concentrated beta weights use a second-order
|
||||
asymptotic approximation to avoid numerical convergence loss at large sample
|
||||
counts.
|
||||
|
||||
Setting `cache` to `true` avoids repeating snapshot capture and expansion when
|
||||
several estimates are requested from an unchanged histogram. The retained
|
||||
snapshot uses memory proportional to the number of occupied HDR buckets and is
|
||||
released when the histogram is next modified.
|
||||
|
||||
QRDE temporarily uses approximately one additional HDR count array plus 32
|
||||
bytes per occupied bucket. With `cache: true`, the expanded 32-byte-per-bucket
|
||||
snapshot remains allocated. The following estimates use `lowest: 1` and
|
||||
`highest: Number.MAX_SAFE_INTEGER` and exclude allocator and JavaScript object
|
||||
overhead:
|
||||
|
||||
| `figures` | Histogram | Maximum expanded snapshot | Peak cache-miss QRDE |
|
||||
| --------- | --------: | ------------------------: | -------------------: |
|
||||
| 1 | 6.3 KiB | 25 KiB | 31 KiB |
|
||||
| 2 | 47 KiB | 188 KiB | 235 KiB |
|
||||
| 3 | 352 KiB | 1.4 MiB | 1.7 MiB |
|
||||
| 4 | 5.0 MiB | 20 MiB | 25 MiB |
|
||||
| 5 | 37 MiB | 148 MiB | 185 MiB |
|
||||
|
||||
The maximum snapshot column assumes every representable bucket is occupied.
|
||||
Lower `highest` values reduce histogram and temporary copy sizes. Concurrent
|
||||
calls that miss the cache each require their own temporary copy and expanded
|
||||
snapshot.
|
||||
|
||||
HDR histograms aggregate observations into equivalent-value buckets. The
|
||||
`'hdr'` dequantization mode models repeated values in buckets wider than one
|
||||
unit as a continuous uniform distribution over the bucket resolution. This
|
||||
reduces density artifacts introduced by HDR quantization while preserving
|
||||
repeated unit-resolution values as point masses. The `'all'` mode also
|
||||
dequantizes repeated unit-resolution values. Use `'none'` to calculate the
|
||||
grouped Harrell-Davis estimator using bucket midpoints directly.
|
||||
|
||||
An empty histogram returns the requested `probabilities` but produces empty
|
||||
`quantiles` and `densities` arrays. A non-dequantized interval whose quantile
|
||||
boundaries are equal has an infinite density.
|
||||
|
||||
### `histogram.reset()`
|
||||
|
||||
<!-- YAML
|
||||
|
||||
@@ -33,9 +33,11 @@ const {
|
||||
|
||||
const {
|
||||
validateArray,
|
||||
validateBoolean,
|
||||
validateInteger,
|
||||
validateNumber,
|
||||
validateObject,
|
||||
validateOneOf,
|
||||
} = require('internal/validators');
|
||||
|
||||
const {
|
||||
@@ -45,6 +47,7 @@ const {
|
||||
const kDestroy = Symbol('kDestroy');
|
||||
const kHandle = Symbol('kHandle');
|
||||
const kRecordable = Symbol('kRecordable');
|
||||
const kQrdeDequantizationModes = ['none', 'hdr', 'all'];
|
||||
|
||||
const {
|
||||
kClone,
|
||||
@@ -594,6 +597,73 @@ class Histogram {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a quantile-respectful density estimate using Harrell-Davis
|
||||
* quantiles. Values can be spread deterministically within their HDR
|
||||
* equivalent-value ranges to reduce quantization artifacts.
|
||||
* @param {{ bins?: number, probabilities?: number[],
|
||||
* dequantize?: 'none'|'hdr'|'all', cache?: boolean }} [options]
|
||||
* @returns {Promise<{
|
||||
* probabilities: Float64Array,
|
||||
* quantiles: Float64Array,
|
||||
* densities: Float64Array,
|
||||
* count: bigint,
|
||||
* bucketCount: number,
|
||||
* corrections: number,
|
||||
* dequantize: 'none'|'hdr'|'all',
|
||||
* }>}
|
||||
*/
|
||||
qrde(options = kEmptyObject) {
|
||||
if (!isHistogram(this))
|
||||
throw new ERR_INVALID_THIS('Histogram');
|
||||
validateObject(options, 'options');
|
||||
const { bins, probabilities, dequantize = 'hdr', cache = false } = options;
|
||||
if (bins !== undefined && probabilities !== undefined) {
|
||||
throw new ERR_INVALID_ARG_VALUE(
|
||||
'options', options, '"bins" and "probabilities" are mutually exclusive');
|
||||
}
|
||||
|
||||
let boundaries;
|
||||
if (probabilities === undefined) {
|
||||
const binCount = bins ?? 100;
|
||||
validateInteger(binCount, 'options.bins', 1, 1000);
|
||||
boundaries = new Float64Array(binCount + 1);
|
||||
for (let i = 0; i <= binCount; i++) boundaries[i] = i / binCount;
|
||||
} else {
|
||||
validateArray(probabilities, 'options.probabilities', 2);
|
||||
const length = probabilities.length;
|
||||
if (length > 1001) {
|
||||
throw new ERR_OUT_OF_RANGE(
|
||||
'options.probabilities.length', '>= 2 && <= 1001', length);
|
||||
}
|
||||
|
||||
boundaries = new Float64Array(length);
|
||||
let previous = -1;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const probability = probabilities[i];
|
||||
validateNumber(probability, `options.probabilities[${i}]`, 0, 1);
|
||||
if (probability <= previous) {
|
||||
throw new ERR_INVALID_ARG_VALUE(
|
||||
'options.probabilities', probabilities, 'must be strictly increasing');
|
||||
}
|
||||
boundaries[i] = probability;
|
||||
previous = probability;
|
||||
}
|
||||
if (boundaries[0] !== 0 || boundaries[length - 1] !== 1) {
|
||||
throw new ERR_INVALID_ARG_VALUE(
|
||||
'options.probabilities', probabilities, 'must start with 0 and end with 1');
|
||||
}
|
||||
}
|
||||
|
||||
validateOneOf(dequantize,
|
||||
'options.dequantize', kQrdeDequantizationModes);
|
||||
validateBoolean(cache, 'options.cache');
|
||||
let mode = 0;
|
||||
if (dequantize === 'hdr') mode = 1;
|
||||
else if (dequantize === 'all') mode = 2;
|
||||
return this[kHandle]?.qrde(boundaries, mode, cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
+19
-5
@@ -33,9 +33,15 @@ void Histogram::UpdateEwma(double value) {
|
||||
}
|
||||
}
|
||||
|
||||
void Histogram::InvalidateRecordedSnapshot() {
|
||||
mutation_generation_++;
|
||||
recorded_snapshot_cache_.reset();
|
||||
}
|
||||
|
||||
void Histogram::Reset() {
|
||||
RwLock::ScopedWriteLock lock(mutex_);
|
||||
hdr_reset(histogram_.get());
|
||||
InvalidateRecordedSnapshot();
|
||||
exceeds_ = 0;
|
||||
prev_ = 0;
|
||||
ewma_mean_ = 0;
|
||||
@@ -49,8 +55,10 @@ double Histogram::Add(const Histogram& other) {
|
||||
exceeds_ += other.exceeds_;
|
||||
if (other.prev_ > prev_) prev_ = other.prev_;
|
||||
// hdr_add merges all bucket counts and total_count internally.
|
||||
return static_cast<double>(
|
||||
hdr_add(histogram_.get(), other.histogram_.get()));
|
||||
const double dropped =
|
||||
static_cast<double>(hdr_add(histogram_.get(), other.histogram_.get()));
|
||||
InvalidateRecordedSnapshot();
|
||||
return dropped;
|
||||
};
|
||||
|
||||
// When adding a histogram to itself, a single write lock suffices.
|
||||
@@ -146,8 +154,10 @@ bool Histogram::RecordCorrected(int64_t value, int64_t expected_interval) {
|
||||
hdr_record_corrected_value(histogram_.get(), value, expected_interval);
|
||||
if (!recorded)
|
||||
exceeds_++;
|
||||
else
|
||||
else {
|
||||
InvalidateRecordedSnapshot();
|
||||
UpdateEwma(static_cast<double>(value));
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
@@ -156,8 +166,10 @@ bool Histogram::Record(int64_t value) {
|
||||
bool recorded = hdr_record_value(histogram_.get(), value);
|
||||
if (!recorded)
|
||||
exceeds_++;
|
||||
else
|
||||
else {
|
||||
InvalidateRecordedSnapshot();
|
||||
UpdateEwma(static_cast<double>(value));
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
@@ -170,8 +182,10 @@ uint64_t Histogram::RecordDelta() {
|
||||
delta = time - prev_;
|
||||
if (!hdr_record_value(histogram_.get(), delta))
|
||||
exceeds_++;
|
||||
else
|
||||
else {
|
||||
InvalidateRecordedSnapshot();
|
||||
UpdateEwma(static_cast<double>(delta));
|
||||
}
|
||||
}
|
||||
prev_ = time;
|
||||
return delta;
|
||||
|
||||
+690
-36
@@ -5,31 +5,42 @@
|
||||
#include "node_debug.h"
|
||||
#include "node_errors.h"
|
||||
#include "node_external_reference.h"
|
||||
#include "threadpoolwork-inl.h"
|
||||
#include "util.h"
|
||||
#include "v8-typed-array.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
|
||||
namespace node {
|
||||
|
||||
using v8::Array;
|
||||
using v8::ArrayBuffer;
|
||||
using v8::BigInt;
|
||||
using v8::CFunction;
|
||||
using v8::Context;
|
||||
using v8::Exception;
|
||||
using v8::Float64Array;
|
||||
using v8::FunctionCallbackInfo;
|
||||
using v8::FunctionTemplate;
|
||||
using v8::Global;
|
||||
using v8::HandleScope;
|
||||
using v8::Integer;
|
||||
using v8::Isolate;
|
||||
using v8::Local;
|
||||
using v8::Map;
|
||||
using v8::Name;
|
||||
using v8::Null;
|
||||
using v8::Number;
|
||||
using v8::Object;
|
||||
using v8::ObjectTemplate;
|
||||
using v8::Promise;
|
||||
using v8::String;
|
||||
using v8::Uint32;
|
||||
using v8::Uint8Array;
|
||||
using v8::Value;
|
||||
|
||||
template <typename T>
|
||||
@@ -66,6 +77,8 @@ std::shared_ptr<Histogram> Histogram::Create(const Options& options) {
|
||||
|
||||
void Histogram::MemoryInfo(MemoryTracker* tracker) const {
|
||||
tracker->TrackFieldWithSize("histogram", GetMemorySize());
|
||||
tracker->TrackFieldWithSize("qrde_snapshot",
|
||||
GetCachedRecordedSnapshotMemorySize());
|
||||
}
|
||||
|
||||
bool Histogram::IsCompatible(const Histogram& other) const {
|
||||
@@ -78,6 +91,99 @@ bool Histogram::IsCompatible(const Histogram& other) const {
|
||||
other.histogram_->significant_figures;
|
||||
}
|
||||
|
||||
Histogram::RecordedSnapshot Histogram::BuildRecordedSnapshot(
|
||||
const hdr_histogram* histogram) {
|
||||
RecordedSnapshot snapshot;
|
||||
snapshot.total_count = histogram->total_count;
|
||||
if (snapshot.total_count == 0) return snapshot;
|
||||
|
||||
const size_t occupied =
|
||||
std::count_if(histogram->counts,
|
||||
histogram->counts + histogram->counts_len,
|
||||
[](int64_t count) { return count != 0; });
|
||||
snapshot.buckets.reserve(occupied);
|
||||
hdr_iter iter;
|
||||
hdr_iter_recorded_init(&iter, histogram);
|
||||
while (hdr_iter_next(&iter)) {
|
||||
snapshot.buckets.push_back({
|
||||
static_cast<double>(iter.median_equivalent_value),
|
||||
static_cast<double>(
|
||||
hdr_size_of_equivalent_value_range(histogram, iter.value)),
|
||||
iter.count,
|
||||
iter.cumulative_count,
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
Histogram::RecordedSnapshotSource Histogram::GetRecordedSnapshotSource(
|
||||
bool use_cache) const {
|
||||
RecordedSnapshotSource source;
|
||||
int64_t lowest;
|
||||
int64_t highest;
|
||||
int figures;
|
||||
{
|
||||
RwLock::ScopedReadLock lock(mutex_);
|
||||
source.generation = mutation_generation_;
|
||||
if (use_cache && recorded_snapshot_cache_) {
|
||||
source.snapshot = recorded_snapshot_cache_;
|
||||
source.cache_hit = true;
|
||||
return source;
|
||||
}
|
||||
if (histogram_->total_count == 0) {
|
||||
source.snapshot = std::make_shared<const RecordedSnapshot>();
|
||||
return source;
|
||||
}
|
||||
lowest = histogram_->lowest_discernible_value;
|
||||
highest = histogram_->highest_trackable_value;
|
||||
figures = histogram_->significant_figures;
|
||||
}
|
||||
|
||||
hdr_histogram* copy;
|
||||
if (hdr_init(lowest, highest, figures, ©) != 0) return source;
|
||||
source.histogram.reset(copy);
|
||||
|
||||
RwLock::ScopedReadLock lock(mutex_);
|
||||
source.generation = mutation_generation_;
|
||||
if (use_cache && recorded_snapshot_cache_) {
|
||||
source.histogram.reset();
|
||||
source.snapshot = recorded_snapshot_cache_;
|
||||
source.cache_hit = true;
|
||||
return source;
|
||||
}
|
||||
if (histogram_->total_count == 0) {
|
||||
source.histogram.reset();
|
||||
source.snapshot = std::make_shared<const RecordedSnapshot>();
|
||||
return source;
|
||||
}
|
||||
|
||||
CHECK_EQ(source.histogram->counts_len, histogram_->counts_len);
|
||||
source.histogram->min_value = histogram_->min_value;
|
||||
source.histogram->max_value = histogram_->max_value;
|
||||
source.histogram->normalizing_index_offset =
|
||||
histogram_->normalizing_index_offset;
|
||||
source.histogram->conversion_ratio = histogram_->conversion_ratio;
|
||||
source.histogram->total_count = histogram_->total_count;
|
||||
std::memcpy(source.histogram->counts,
|
||||
histogram_->counts,
|
||||
histogram_->counts_len * sizeof(*histogram_->counts));
|
||||
return source;
|
||||
}
|
||||
|
||||
void Histogram::CacheRecordedSnapshot(
|
||||
uint64_t generation, std::shared_ptr<const RecordedSnapshot> snapshot) {
|
||||
RwLock::ScopedWriteLock lock(mutex_);
|
||||
if (generation == mutation_generation_) {
|
||||
recorded_snapshot_cache_ = std::move(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
size_t Histogram::GetCachedRecordedSnapshotMemorySize() const {
|
||||
RwLock::ScopedReadLock lock(mutex_);
|
||||
if (!recorded_snapshot_cache_) return 0;
|
||||
return recorded_snapshot_cache_->buckets.capacity() * sizeof(RecordedBucket);
|
||||
}
|
||||
|
||||
double Histogram::Cdf(int64_t value) const {
|
||||
RwLock::ScopedReadLock lock(mutex_);
|
||||
int64_t total = histogram_->total_count;
|
||||
@@ -169,6 +275,7 @@ double Histogram::Subtract(const Histogram& other) {
|
||||
histogram_->counts[i] = count;
|
||||
}
|
||||
hdr_reset_internal_counters(histogram_.get());
|
||||
InvalidateRecordedSnapshot();
|
||||
exceeds_ = (exceeds_ > other.exceeds_) ? exceeds_ - other.exceeds_ : 0;
|
||||
return static_cast<double>(dropped);
|
||||
};
|
||||
@@ -273,22 +380,157 @@ static double BetaContinuedFraction(double a, double b, double x) {
|
||||
return h;
|
||||
}
|
||||
|
||||
struct BetaParameters {
|
||||
double a;
|
||||
double b;
|
||||
double log_normalization;
|
||||
double mean;
|
||||
double standard_deviation;
|
||||
double skewness;
|
||||
double excess_kurtosis;
|
||||
double log_scale;
|
||||
double symmetry_point;
|
||||
bool use_asymptotic_front;
|
||||
bool use_asymptotic_cdf;
|
||||
};
|
||||
|
||||
static double StirlingCorrection(double x) {
|
||||
const double inverse = 1.0 / x;
|
||||
const double inverse_squared = inverse * inverse;
|
||||
return inverse * (1.0 / 12.0 +
|
||||
inverse_squared *
|
||||
(-1.0 / 360.0 +
|
||||
inverse_squared *
|
||||
(1.0 / 1260.0 +
|
||||
inverse_squared *
|
||||
(-1.0 / 1680.0 + inverse_squared / 1188.0))));
|
||||
}
|
||||
|
||||
static BetaParameters MakeBetaParameters(double a,
|
||||
double b,
|
||||
bool approximate_large_shapes) {
|
||||
const double sum = a + b;
|
||||
const double mean = a / sum;
|
||||
const bool use_asymptotic_front =
|
||||
approximate_large_shapes && a >= 8.0 && b >= 8.0;
|
||||
// The continued fraction needs progressively more iterations as both
|
||||
// beta shapes grow. At this concentration the Edgeworth error is smaller
|
||||
// than the continued-fraction truncation error.
|
||||
const bool use_asymptotic_cdf =
|
||||
approximate_large_shapes && a * b / sum >= 25'000.0;
|
||||
const double variance = mean * (1.0 - mean) / (sum + 1.0);
|
||||
const double skewness =
|
||||
2.0 * (b - a) * std::sqrt(sum + 1.0) / ((sum + 2.0) * std::sqrt(a * b));
|
||||
const double excess_kurtosis =
|
||||
6.0 * ((a - b) * (a - b) * (sum + 1.0) - a * b * (sum + 2.0)) /
|
||||
(a * b * (sum + 2.0) * (sum + 3.0));
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
use_asymptotic_front ? 0.0
|
||||
: std::lgamma(sum) - std::lgamma(a) - std::lgamma(b),
|
||||
mean,
|
||||
std::sqrt(variance),
|
||||
skewness,
|
||||
excess_kurtosis,
|
||||
use_asymptotic_front ? 0.5 * std::log(sum * mean * (1.0 - mean) /
|
||||
(2.0 * std::numbers::pi)) +
|
||||
StirlingCorrection(sum) -
|
||||
StirlingCorrection(a) - StirlingCorrection(b)
|
||||
: 0.0,
|
||||
(a + 1.0) / (sum + 2.0),
|
||||
use_asymptotic_front,
|
||||
use_asymptotic_cdf,
|
||||
};
|
||||
}
|
||||
|
||||
static BetaParameters ReflectBetaParameters(const BetaParameters& params) {
|
||||
return {
|
||||
params.b,
|
||||
params.a,
|
||||
params.log_normalization,
|
||||
1.0 - params.mean,
|
||||
params.standard_deviation,
|
||||
-params.skewness,
|
||||
params.excess_kurtosis,
|
||||
params.log_scale,
|
||||
1.0 - params.symmetry_point,
|
||||
params.use_asymptotic_front,
|
||||
params.use_asymptotic_cdf,
|
||||
};
|
||||
}
|
||||
|
||||
static double BetaFront(const BetaParameters& params, double x) {
|
||||
if (x <= 0.0 || x >= 1.0) return 0.0;
|
||||
double log_front;
|
||||
if (params.use_asymptotic_front) {
|
||||
log_front = params.a * std::log1p((x - params.mean) / params.mean) +
|
||||
params.b * std::log1p((params.mean - x) / (1.0 - params.mean)) +
|
||||
params.log_scale;
|
||||
} else {
|
||||
log_front = params.log_normalization + params.a * std::log(x) +
|
||||
params.b * std::log(1.0 - x);
|
||||
}
|
||||
return std::exp(log_front);
|
||||
}
|
||||
|
||||
static double AsymptoticBetaCdf(const BetaParameters& params,
|
||||
double x,
|
||||
double* front_out) {
|
||||
const double z = (x - params.mean) / params.standard_deviation;
|
||||
const double normal_cdf = 0.5 * std::erfc(-z / std::numbers::sqrt2);
|
||||
if (std::fabs(z) >= 8.0) {
|
||||
if (front_out != nullptr) *front_out = 0.0;
|
||||
return normal_cdf;
|
||||
}
|
||||
|
||||
const double z2 = z * z;
|
||||
const double z3 = z2 * z;
|
||||
const double normal_pdf =
|
||||
std::exp(-0.5 * z2) / std::sqrt(2.0 * std::numbers::pi);
|
||||
if (front_out != nullptr) {
|
||||
const double z4 = z2 * z2;
|
||||
const double z6 = z3 * z3;
|
||||
const double density_correction =
|
||||
1.0 + params.skewness / 6.0 * (z3 - 3.0 * z) +
|
||||
params.excess_kurtosis / 24.0 * (z4 - 6.0 * z2 + 3.0) +
|
||||
params.skewness * params.skewness / 72.0 *
|
||||
(z6 - 15.0 * z4 + 45.0 * z2 - 15.0);
|
||||
*front_out = x * (1.0 - x) * normal_pdf / params.standard_deviation *
|
||||
std::max(0.0, density_correction);
|
||||
}
|
||||
const double correction = params.skewness / 6.0 * (1.0 - z2) -
|
||||
params.excess_kurtosis / 24.0 * (z3 - 3.0 * z) -
|
||||
params.skewness * params.skewness / 72.0 *
|
||||
(z3 * z2 - 10.0 * z3 + 15.0 * z);
|
||||
return std::clamp(normal_cdf + normal_pdf * correction, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Regularized incomplete beta function I_x(a, b).
|
||||
// Returns the probability that a Beta(a,b) random variable is <= x.
|
||||
static double RegularizedIncompleteBeta(double a, double b, double x) {
|
||||
static double RegularizedIncompleteBeta(const BetaParameters& params,
|
||||
double x,
|
||||
double* front_out = nullptr) {
|
||||
if (front_out != nullptr) *front_out = 0.0;
|
||||
if (x <= 0.0) return 0.0;
|
||||
if (x >= 1.0) return 1.0;
|
||||
|
||||
double ln_front = std::lgamma(a + b) - std::lgamma(a) - std::lgamma(b) +
|
||||
a * std::log(x) + b * std::log(1.0 - x);
|
||||
double bt = std::exp(ln_front);
|
||||
if (params.use_asymptotic_cdf) {
|
||||
return AsymptoticBetaCdf(params, x, front_out);
|
||||
}
|
||||
const double front = BetaFront(params, x);
|
||||
if (front_out != nullptr) *front_out = front;
|
||||
|
||||
// Use the symmetry relation to ensure the continued fraction
|
||||
// converges in the region where it is most accurate.
|
||||
if (x < (a + 1.0) / (a + b + 2.0)) {
|
||||
return bt * BetaContinuedFraction(a, b, x) / a;
|
||||
if (x < params.symmetry_point) {
|
||||
return front * BetaContinuedFraction(params.a, params.b, x) / params.a;
|
||||
}
|
||||
return 1.0 - bt * BetaContinuedFraction(b, a, 1.0 - x) / b;
|
||||
return 1.0 -
|
||||
front * BetaContinuedFraction(params.b, params.a, 1.0 - x) / params.b;
|
||||
}
|
||||
|
||||
static double RegularizedIncompleteBeta(double a, double b, double x) {
|
||||
return RegularizedIncompleteBeta(MakeBetaParameters(a, b, false), x);
|
||||
}
|
||||
|
||||
// Standard normal CDF: Phi(x) = P(Z <= x).
|
||||
@@ -338,6 +580,381 @@ static double BinomialCdf(int64_t k, int64_t n, double p) {
|
||||
static_cast<double>(n - k), static_cast<double>(k + 1), 1.0 - p);
|
||||
}
|
||||
|
||||
struct QrdeResult {
|
||||
std::vector<double> quantiles;
|
||||
std::vector<double> densities;
|
||||
size_t corrections = 0;
|
||||
};
|
||||
|
||||
enum class QrdeDequantization : uint32_t {
|
||||
kNone,
|
||||
kHdr,
|
||||
kAll,
|
||||
};
|
||||
|
||||
static bool QrdeShouldDequantize(const Histogram::RecordedBucket& bucket,
|
||||
QrdeDequantization dequantization) {
|
||||
return bucket.count > 1 && (dequantization == QrdeDequantization::kAll ||
|
||||
(dequantization == QrdeDequantization::kHdr &&
|
||||
bucket.resolution > 1.0));
|
||||
}
|
||||
|
||||
static std::pair<double, double> QrdeBucketRange(
|
||||
const Histogram::RecordedSnapshot& snapshot,
|
||||
size_t index,
|
||||
QrdeDequantization dequantization) {
|
||||
const auto& bucket = snapshot.buckets[index];
|
||||
if (!QrdeShouldDequantize(bucket, dequantization)) {
|
||||
return {bucket.value, bucket.value};
|
||||
}
|
||||
|
||||
const double half_resolution = bucket.resolution / 2.0;
|
||||
if (snapshot.buckets.size() == 1) {
|
||||
return {bucket.value - half_resolution, bucket.value + half_resolution};
|
||||
}
|
||||
if (index == 0) {
|
||||
return {bucket.value, bucket.value + half_resolution};
|
||||
}
|
||||
if (index + 1 == snapshot.buckets.size()) {
|
||||
return {bucket.value - half_resolution, bucket.value};
|
||||
}
|
||||
return {bucket.value - half_resolution, bucket.value + half_resolution};
|
||||
}
|
||||
|
||||
static bool QrdeExactSupport(const Histogram::RecordedSnapshot& snapshot,
|
||||
const BetaParameters& params,
|
||||
const BetaParameters& reflected,
|
||||
double count,
|
||||
size_t* begin_index,
|
||||
size_t* end_index) {
|
||||
// Collapsing both tails changes a quantile by at most 2^-79 times the
|
||||
// histogram range, which is less than 2^-16 over the int64 value domain.
|
||||
constexpr double tail_mass = 0x1p-80;
|
||||
size_t lo = 0;
|
||||
size_t hi = snapshot.buckets.size();
|
||||
while (lo < hi) {
|
||||
const size_t mid = lo + (hi - lo) / 2;
|
||||
const double rank =
|
||||
static_cast<double>(snapshot.buckets[mid].cumulative_count) / count;
|
||||
const double cdf = RegularizedIncompleteBeta(params, rank);
|
||||
if (!std::isfinite(cdf)) return false;
|
||||
if (cdf < tail_mass) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
if (lo == snapshot.buckets.size()) return false;
|
||||
const size_t first = lo;
|
||||
|
||||
lo = first;
|
||||
hi = snapshot.buckets.size();
|
||||
while (lo < hi) {
|
||||
const size_t mid = lo + (hi - lo) / 2;
|
||||
const double remaining_rank =
|
||||
static_cast<double>(snapshot.total_count -
|
||||
snapshot.buckets[mid].cumulative_count) /
|
||||
count;
|
||||
const double survival =
|
||||
RegularizedIncompleteBeta(reflected, remaining_rank);
|
||||
if (!std::isfinite(survival)) return false;
|
||||
if (survival > tail_mass) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
if (lo == snapshot.buckets.size() || lo < first) return false;
|
||||
*begin_index = first;
|
||||
*end_index = lo + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
static double QrdeQuantile(const Histogram::RecordedSnapshot& snapshot,
|
||||
double p,
|
||||
QrdeDequantization dequantization) {
|
||||
const double count = static_cast<double>(snapshot.total_count);
|
||||
const double a = (count + 1.0) * p;
|
||||
const double b = (count + 1.0) * (1.0 - p);
|
||||
const BetaParameters mass_params = MakeBetaParameters(a, b, true);
|
||||
const BetaParameters reflected_params = ReflectBetaParameters(mass_params);
|
||||
|
||||
size_t begin_index = 0;
|
||||
size_t end_index = snapshot.buckets.size();
|
||||
if (mass_params.use_asymptotic_cdf) {
|
||||
// Outside this interval the normal-tail mass is below 2e-33.
|
||||
constexpr double support_deviations = 12.0;
|
||||
const double lower_rank = std::max(
|
||||
0.0,
|
||||
mass_params.mean - support_deviations * mass_params.standard_deviation);
|
||||
const double upper_rank = std::min(
|
||||
1.0,
|
||||
mass_params.mean + support_deviations * mass_params.standard_deviation);
|
||||
const auto first = std::lower_bound(
|
||||
snapshot.buckets.begin(),
|
||||
snapshot.buckets.end(),
|
||||
lower_rank * count,
|
||||
[](const Histogram::RecordedBucket& bucket, double cumulative) {
|
||||
return static_cast<double>(bucket.cumulative_count) < cumulative;
|
||||
});
|
||||
const auto last = std::lower_bound(
|
||||
first,
|
||||
snapshot.buckets.end(),
|
||||
upper_rank * count,
|
||||
[](const Histogram::RecordedBucket& bucket, double cumulative) {
|
||||
return static_cast<double>(bucket.cumulative_count) < cumulative;
|
||||
});
|
||||
begin_index = first - snapshot.buckets.begin();
|
||||
if (last != snapshot.buckets.end()) {
|
||||
end_index = last - snapshot.buckets.begin() + 1;
|
||||
}
|
||||
} else if (snapshot.buckets.size() >= 512) {
|
||||
QrdeExactSupport(snapshot,
|
||||
mass_params,
|
||||
reflected_params,
|
||||
count,
|
||||
&begin_index,
|
||||
&end_index);
|
||||
}
|
||||
|
||||
const bool dequantize = dequantization != QrdeDequantization::kNone;
|
||||
const auto first_range = QrdeBucketRange(snapshot, 0, dequantization);
|
||||
const auto last_range =
|
||||
QrdeBucketRange(snapshot, snapshot.buckets.size() - 1, dequantization);
|
||||
const double lower_endpoint = first_range.first;
|
||||
const double upper_endpoint = last_range.second;
|
||||
|
||||
double previous_cdf = 0.0;
|
||||
double previous_survival = 0.0;
|
||||
double previous_front = 0.0;
|
||||
int64_t previous_count =
|
||||
begin_index == 0 ? 0 : snapshot.buckets[begin_index - 1].cumulative_count;
|
||||
if (previous_count != 0) {
|
||||
const double previous_rank = static_cast<double>(previous_count) / count;
|
||||
previous_cdf = RegularizedIncompleteBeta(
|
||||
mass_params, previous_rank, dequantize ? &previous_front : nullptr);
|
||||
if (!std::isfinite(previous_cdf) ||
|
||||
(dequantize && !std::isfinite(previous_front))) {
|
||||
begin_index = 0;
|
||||
end_index = snapshot.buckets.size();
|
||||
previous_count = 0;
|
||||
previous_cdf = 0.0;
|
||||
previous_front = 0.0;
|
||||
} else {
|
||||
previous_cdf = std::clamp(previous_cdf, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
double quantile = lower_endpoint * previous_cdf;
|
||||
bool using_survival = false;
|
||||
|
||||
for (size_t i = begin_index; i < end_index; i++) {
|
||||
const auto& bucket = snapshot.buckets[i];
|
||||
const double u0 = static_cast<double>(previous_count) / count;
|
||||
const double u1 = static_cast<double>(bucket.cumulative_count) / count;
|
||||
double front = 0.0;
|
||||
double mass;
|
||||
// Use the reflected CDF above the symmetry point so small upper-tail
|
||||
// interval weights are not lost to subtraction from one.
|
||||
if (u1 <= mass_params.symmetry_point) {
|
||||
const double cdf =
|
||||
std::clamp(RegularizedIncompleteBeta(
|
||||
mass_params, u1, dequantize ? &front : nullptr),
|
||||
previous_cdf,
|
||||
1.0);
|
||||
mass = cdf - previous_cdf;
|
||||
previous_cdf = cdf;
|
||||
} else {
|
||||
const double remaining_rank =
|
||||
static_cast<double>(snapshot.total_count - bucket.cumulative_count) /
|
||||
count;
|
||||
double survival = RegularizedIncompleteBeta(
|
||||
reflected_params, remaining_rank, dequantize ? &front : nullptr);
|
||||
if (using_survival) {
|
||||
survival = std::clamp(survival, 0.0, previous_survival);
|
||||
mass = previous_survival - survival;
|
||||
} else {
|
||||
survival = std::clamp(survival, 0.0, 1.0 - previous_cdf);
|
||||
mass = 1.0 - previous_cdf - survival;
|
||||
using_survival = true;
|
||||
}
|
||||
previous_survival = survival;
|
||||
}
|
||||
|
||||
if (!QrdeShouldDequantize(bucket, dequantization)) {
|
||||
quantile += bucket.value * mass;
|
||||
} else {
|
||||
// I_x(a + 1, b) = I_x(a, b) - front / a, so the first moment
|
||||
// within this rank interval does not require a second beta CDF.
|
||||
const double sum = a + b;
|
||||
const double rank_width = static_cast<double>(bucket.count) / count;
|
||||
const double local_moment =
|
||||
std::clamp((a / sum - u0) * mass - (front - previous_front) / sum,
|
||||
0.0,
|
||||
rank_width * mass);
|
||||
const auto [lower, upper] = QrdeBucketRange(snapshot, i, dequantization);
|
||||
quantile += lower * mass + (upper - lower) * local_moment / rank_width;
|
||||
}
|
||||
|
||||
previous_front = front;
|
||||
previous_count = bucket.cumulative_count;
|
||||
}
|
||||
|
||||
const double remaining_mass =
|
||||
using_survival ? previous_survival : 1.0 - previous_cdf;
|
||||
quantile += upper_endpoint * std::clamp(remaining_mass, 0.0, 1.0);
|
||||
return quantile;
|
||||
}
|
||||
|
||||
static QrdeResult CalculateQrde(const Histogram::RecordedSnapshot& snapshot,
|
||||
const std::vector<double>& probabilities,
|
||||
QrdeDequantization dequantization) {
|
||||
QrdeResult result;
|
||||
if (snapshot.buckets.empty()) return result;
|
||||
|
||||
result.quantiles.resize(probabilities.size());
|
||||
result.densities.resize(probabilities.size() - 1);
|
||||
|
||||
const auto first_range = QrdeBucketRange(snapshot, 0, dequantization);
|
||||
const auto last_range =
|
||||
QrdeBucketRange(snapshot, snapshot.buckets.size() - 1, dequantization);
|
||||
result.quantiles.front() = first_range.first;
|
||||
result.quantiles.back() = last_range.second;
|
||||
|
||||
for (size_t i = 1; i + 1 < probabilities.size(); i++) {
|
||||
double value = QrdeQuantile(snapshot, probabilities[i], dequantization);
|
||||
value =
|
||||
std::clamp(value, result.quantiles.front(), result.quantiles.back());
|
||||
if (value < result.quantiles[i - 1]) {
|
||||
value = result.quantiles[i - 1];
|
||||
result.corrections++;
|
||||
}
|
||||
result.quantiles[i] = value;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < result.densities.size(); i++) {
|
||||
const double width = result.quantiles[i + 1] - result.quantiles[i];
|
||||
const double probability_mass = probabilities[i + 1] - probabilities[i];
|
||||
result.densities[i] = width == 0.0 ? std::numeric_limits<double>::infinity()
|
||||
: probability_mass / width;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static Local<Float64Array> ToFloat64Array(Isolate* isolate,
|
||||
const std::vector<double>& values) {
|
||||
auto store =
|
||||
ArrayBuffer::NewBackingStore(isolate, values.size() * sizeof(double));
|
||||
if (!values.empty()) {
|
||||
memcpy(store->Data(), values.data(), values.size() * sizeof(double));
|
||||
}
|
||||
Local<ArrayBuffer> buffer = ArrayBuffer::New(isolate, std::move(store));
|
||||
return Float64Array::New(buffer, 0, values.size());
|
||||
}
|
||||
|
||||
static const char* QrdeDequantizationName(QrdeDequantization dequantization) {
|
||||
switch (dequantization) {
|
||||
case QrdeDequantization::kNone:
|
||||
return "none";
|
||||
case QrdeDequantization::kHdr:
|
||||
return "hdr";
|
||||
case QrdeDequantization::kAll:
|
||||
return "all";
|
||||
}
|
||||
UNREACHABLE();
|
||||
}
|
||||
|
||||
class QrdeJob final : public ThreadPoolWork {
|
||||
public:
|
||||
QrdeJob(Environment* env,
|
||||
Local<Promise::Resolver> resolver,
|
||||
std::shared_ptr<Histogram> histogram,
|
||||
Histogram::RecordedSnapshotSource snapshot_source,
|
||||
std::vector<double> probabilities,
|
||||
QrdeDequantization dequantization)
|
||||
: ThreadPoolWork(env, "histogram.qrde"),
|
||||
histogram_(std::move(histogram)),
|
||||
snapshot_source_(std::move(snapshot_source)),
|
||||
probabilities_(std::move(probabilities)),
|
||||
dequantization_(dequantization) {
|
||||
resolver_.Reset(env->isolate(), resolver);
|
||||
}
|
||||
|
||||
void DoThreadPoolWork() override {
|
||||
if (snapshot_source_.snapshot) {
|
||||
snapshot_ = std::move(snapshot_source_.snapshot);
|
||||
} else {
|
||||
snapshot_ = std::make_shared<const Histogram::RecordedSnapshot>(
|
||||
Histogram::BuildRecordedSnapshot(snapshot_source_.histogram.get()));
|
||||
snapshot_source_.histogram.reset();
|
||||
}
|
||||
if (histogram_) {
|
||||
histogram_->CacheRecordedSnapshot(snapshot_source_.generation, snapshot_);
|
||||
histogram_.reset();
|
||||
}
|
||||
result_ = CalculateQrde(*snapshot_, probabilities_, dequantization_);
|
||||
}
|
||||
|
||||
void AfterThreadPoolWork(int status) override {
|
||||
std::unique_ptr<QrdeJob> self(this);
|
||||
Environment* env = ThreadPoolWork::env();
|
||||
CHECK(status == 0 || status == UV_ECANCELED);
|
||||
if (!env->can_call_into_js()) {
|
||||
resolver_.Reset();
|
||||
return;
|
||||
}
|
||||
|
||||
Isolate* isolate = env->isolate();
|
||||
HandleScope handle_scope(isolate);
|
||||
Context::Scope context_scope(env->context());
|
||||
InternalCallbackScope callback_scope(
|
||||
env, Object::New(isolate), {0, 0}, InternalCallbackScope::kNoFlags);
|
||||
Local<Promise::Resolver> resolver =
|
||||
Local<Promise::Resolver>::New(isolate, resolver_);
|
||||
|
||||
if (status == UV_ECANCELED) {
|
||||
USE(resolver->Reject(
|
||||
env->context(),
|
||||
Exception::Error(OneByteString(isolate, "QRDE job was canceled"))));
|
||||
resolver_.Reset();
|
||||
return;
|
||||
}
|
||||
|
||||
Local<Name> names[] = {
|
||||
FIXED_ONE_BYTE_STRING(isolate, "probabilities"),
|
||||
FIXED_ONE_BYTE_STRING(isolate, "quantiles"),
|
||||
FIXED_ONE_BYTE_STRING(isolate, "densities"),
|
||||
FIXED_ONE_BYTE_STRING(isolate, "count"),
|
||||
FIXED_ONE_BYTE_STRING(isolate, "bucketCount"),
|
||||
FIXED_ONE_BYTE_STRING(isolate, "corrections"),
|
||||
FIXED_ONE_BYTE_STRING(isolate, "dequantize"),
|
||||
};
|
||||
Local<Value> values[] = {
|
||||
ToFloat64Array(isolate, probabilities_),
|
||||
ToFloat64Array(isolate, result_.quantiles),
|
||||
ToFloat64Array(isolate, result_.densities),
|
||||
BigInt::New(isolate, snapshot_->total_count),
|
||||
Number::New(isolate, static_cast<double>(snapshot_->buckets.size())),
|
||||
Number::New(isolate, static_cast<double>(result_.corrections)),
|
||||
OneByteString(isolate, QrdeDequantizationName(dequantization_)),
|
||||
};
|
||||
Local<Object> value =
|
||||
Object::New(isolate, Null(isolate), names, values, arraysize(names));
|
||||
USE(resolver->Resolve(env->context(), value));
|
||||
resolver_.Reset();
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<Histogram> histogram_;
|
||||
Histogram::RecordedSnapshotSource snapshot_source_;
|
||||
std::shared_ptr<const Histogram::RecordedSnapshot> snapshot_;
|
||||
const std::vector<double> probabilities_;
|
||||
const QrdeDequantization dequantization_;
|
||||
QrdeResult result_;
|
||||
Global<Promise::Resolver> resolver_;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Minimal CBOR encoder/decoder (RFC 8949) -- just enough types for
|
||||
// histogram export/import: unsigned int, float64, array, and map.
|
||||
@@ -1176,6 +1793,7 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local<FunctionTemplate> tmpl) {
|
||||
SetProtoMethodNoSideEffect(isolate, tmpl, "cohensD", GetCohensD);
|
||||
SetProtoMethodNoSideEffect(isolate, tmpl, "cliffsD", GetCliffsD);
|
||||
SetProtoMethodNoSideEffect(isolate, tmpl, "percentileCI", GetPercentileCI);
|
||||
SetProtoMethod(isolate, tmpl, "qrde", GetQrde);
|
||||
SetFastMethodNoSideEffect(
|
||||
isolate, instance, "ewmaMean", GetEwmaMean, &fast_get_ewma_mean_);
|
||||
SetFastMethodNoSideEffect(
|
||||
@@ -1230,6 +1848,7 @@ void HistogramImpl::RegisterExternalReferences(
|
||||
registry->Register(GetCohensD);
|
||||
registry->Register(GetCliffsD);
|
||||
registry->Register(GetPercentileCI);
|
||||
registry->Register(GetQrde);
|
||||
registry->Register(GetEwmaMean);
|
||||
registry->Register(GetEwmaStddev);
|
||||
registry->Register(GetEwmaErrorRate);
|
||||
@@ -1244,12 +1863,10 @@ void HistogramImpl::RegisterExternalReferences(
|
||||
is_registered = true;
|
||||
}
|
||||
|
||||
HistogramBase::HistogramBase(
|
||||
Environment* env,
|
||||
Local<Object> wrap,
|
||||
const Histogram::Options& options)
|
||||
: BaseObject(env, wrap),
|
||||
HistogramImpl(options) {
|
||||
HistogramBase::HistogramBase(Environment* env,
|
||||
Local<Object> wrap,
|
||||
const Histogram::Options& options)
|
||||
: BaseObject(env, wrap), HistogramImpl(options) {
|
||||
MakeWeak();
|
||||
wrap->SetAlignedPointerInInternalField(
|
||||
HistogramImpl::InternalFields::kImplField,
|
||||
@@ -1257,12 +1874,10 @@ HistogramBase::HistogramBase(
|
||||
EmbedderDataTag::kDefault);
|
||||
}
|
||||
|
||||
HistogramBase::HistogramBase(
|
||||
Environment* env,
|
||||
Local<Object> wrap,
|
||||
std::shared_ptr<Histogram> histogram)
|
||||
: BaseObject(env, wrap),
|
||||
HistogramImpl(std::move(histogram)) {
|
||||
HistogramBase::HistogramBase(Environment* env,
|
||||
Local<Object> wrap,
|
||||
std::shared_ptr<Histogram> histogram)
|
||||
: BaseObject(env, wrap), HistogramImpl(std::move(histogram)) {
|
||||
MakeWeak();
|
||||
wrap->SetAlignedPointerInInternalField(
|
||||
HistogramImpl::InternalFields::kImplField,
|
||||
@@ -1292,8 +1907,8 @@ void HistogramBase::Record(const FunctionCallbackInfo<Value>& args) {
|
||||
CHECK_IMPLIES(!args[0]->IsNumber(), args[0]->IsBigInt());
|
||||
bool lossless = true;
|
||||
int64_t value = args[0]->IsBigInt()
|
||||
? args[0].As<BigInt>()->Int64Value(&lossless)
|
||||
: static_cast<int64_t>(args[0].As<Number>()->Value());
|
||||
? args[0].As<BigInt>()->Int64Value(&lossless)
|
||||
: static_cast<int64_t>(args[0].As<Number>()->Value());
|
||||
if (!lossless || value < 1)
|
||||
return THROW_ERR_OUT_OF_RANGE(env, "value is out of range");
|
||||
HistogramBase* histogram;
|
||||
@@ -1356,8 +1971,7 @@ void HistogramBase::RecordCorrected(const FunctionCallbackInfo<Value>& args) {
|
||||
}
|
||||
|
||||
BaseObjectPtr<HistogramBase> HistogramBase::Create(
|
||||
Environment* env,
|
||||
const Histogram::Options& options) {
|
||||
Environment* env, const Histogram::Options& options) {
|
||||
Local<Object> obj;
|
||||
if (!GetConstructorTemplate(env->isolate_data())
|
||||
->InstanceTemplate()
|
||||
@@ -1370,8 +1984,7 @@ BaseObjectPtr<HistogramBase> HistogramBase::Create(
|
||||
}
|
||||
|
||||
BaseObjectPtr<HistogramBase> HistogramBase::Create(
|
||||
Environment* env,
|
||||
std::shared_ptr<Histogram> histogram) {
|
||||
Environment* env, std::shared_ptr<Histogram> histogram) {
|
||||
Local<Object> obj;
|
||||
if (!GetConstructorTemplate(env->isolate_data())
|
||||
->InstanceTemplate()
|
||||
@@ -1538,8 +2151,9 @@ BaseObjectPtr<IntervalHistogram> IntervalHistogram::Create(
|
||||
AsyncWrap::ProviderType type) {
|
||||
Local<Object> obj;
|
||||
if (!GetConstructorTemplate(env)
|
||||
->InstanceTemplate()
|
||||
->NewInstance(env->context()).ToLocal(&obj)) {
|
||||
->InstanceTemplate()
|
||||
->NewInstance(env->context())
|
||||
.ToLocal(&obj)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -1563,8 +2177,7 @@ void IntervalHistogram::MemoryInfo(MemoryTracker* tracker) const {
|
||||
void IntervalHistogram::OnStart(StartFlags flags) {
|
||||
if (enabled_ || IsHandleClosing()) return;
|
||||
enabled_ = true;
|
||||
if (flags == StartFlags::RESET)
|
||||
histogram()->Reset();
|
||||
if (flags == StartFlags::RESET) histogram()->Reset();
|
||||
uv_timer_start(&timer_, TimerCB, interval_, interval_);
|
||||
uv_unref(reinterpret_cast<uv_handle_t*>(&timer_));
|
||||
}
|
||||
@@ -2011,6 +2624,47 @@ void HistogramImpl::GetPercentileCI(const FunctionCallbackInfo<Value>& args) {
|
||||
args.GetReturnValue().Set(arr);
|
||||
}
|
||||
|
||||
void HistogramImpl::GetQrde(const FunctionCallbackInfo<Value>& args) {
|
||||
Environment* env = Environment::GetCurrent(args);
|
||||
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
|
||||
CHECK(args[0]->IsFloat64Array());
|
||||
CHECK(args[1]->IsUint32());
|
||||
CHECK(args[2]->IsBoolean());
|
||||
Local<Float64Array> input = args[0].As<Float64Array>();
|
||||
CHECK_GE(input->Length(), 2);
|
||||
CHECK_LE(input->Length(), 1001);
|
||||
auto backing = input->Buffer()->GetBackingStore();
|
||||
const double* data = reinterpret_cast<const double*>(
|
||||
static_cast<const char*>(backing->Data()) + input->ByteOffset());
|
||||
std::vector<double> probabilities(data, data + input->Length());
|
||||
const uint32_t dequantization = args[1].As<Uint32>()->Value();
|
||||
CHECK_LE(dequantization, static_cast<uint32_t>(QrdeDequantization::kAll));
|
||||
const bool cache_snapshot = args[2]->IsTrue();
|
||||
|
||||
Local<Promise::Resolver> resolver;
|
||||
if (!Promise::Resolver::New(env->context()).ToLocal(&resolver)) return;
|
||||
|
||||
auto histogram_ptr = histogram->histogram();
|
||||
auto snapshot_source =
|
||||
histogram_ptr->GetRecordedSnapshotSource(cache_snapshot);
|
||||
if (!snapshot_source.histogram && !snapshot_source.snapshot) {
|
||||
THROW_ERR_MEMORY_ALLOCATION_FAILED(env);
|
||||
return;
|
||||
}
|
||||
std::shared_ptr<Histogram> cache_target;
|
||||
if (cache_snapshot && !snapshot_source.cache_hit) {
|
||||
cache_target = histogram_ptr;
|
||||
}
|
||||
auto* job = new QrdeJob(env,
|
||||
resolver,
|
||||
std::move(cache_target),
|
||||
std::move(snapshot_source),
|
||||
std::move(probabilities),
|
||||
static_cast<QrdeDequantization>(dequantization));
|
||||
args.GetReturnValue().Set(resolver->GetPromise());
|
||||
job->ScheduleWork();
|
||||
}
|
||||
|
||||
void HistogramImpl::GetEwmaMean(const FunctionCallbackInfo<Value>& args) {
|
||||
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
|
||||
args.GetReturnValue().Set((*histogram)->EwmaMean());
|
||||
@@ -2049,10 +2703,10 @@ void HistogramImpl::DoExport(const FunctionCallbackInfo<Value>& args) {
|
||||
HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This());
|
||||
std::vector<uint8_t> data = (*histogram)->Export();
|
||||
|
||||
auto store = v8::ArrayBuffer::NewBackingStore(env->isolate(), data.size());
|
||||
auto store = ArrayBuffer::NewBackingStore(env->isolate(), data.size());
|
||||
memcpy(store->Data(), data.data(), data.size());
|
||||
auto buf = v8::ArrayBuffer::New(env->isolate(), std::move(store));
|
||||
auto arr = v8::Uint8Array::New(buf, 0, data.size());
|
||||
auto buf = ArrayBuffer::New(env->isolate(), std::move(store));
|
||||
auto arr = Uint8Array::New(buf, 0, data.size());
|
||||
args.GetReturnValue().Set(arr);
|
||||
}
|
||||
|
||||
@@ -2062,7 +2716,7 @@ void HistogramImpl::DoImport(const FunctionCallbackInfo<Value>& args) {
|
||||
THROW_ERR_INVALID_ARG_TYPE(env, "data must be a Uint8Array");
|
||||
return;
|
||||
}
|
||||
Local<v8::Uint8Array> input = args[0].As<v8::Uint8Array>();
|
||||
Local<Uint8Array> input = args[0].As<Uint8Array>();
|
||||
auto backing = input->Buffer()->GetBackingStore();
|
||||
const uint8_t* data =
|
||||
static_cast<const uint8_t*>(backing->Data()) + input->ByteOffset();
|
||||
@@ -2163,8 +2817,8 @@ std::unique_ptr<worker::TransferData> IterationHistogram::CloneForMessaging()
|
||||
return std::make_unique<HistogramBase::HistogramTransferData>(histogram());
|
||||
}
|
||||
|
||||
std::unique_ptr<worker::TransferData>
|
||||
IntervalHistogram::CloneForMessaging() const {
|
||||
std::unique_ptr<worker::TransferData> IntervalHistogram::CloneForMessaging()
|
||||
const {
|
||||
return std::make_unique<HistogramBase::HistogramTransferData>(histogram());
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,27 @@ class Histogram : public MemoryRetainer {
|
||||
// exceeding this threshold.
|
||||
};
|
||||
|
||||
struct RecordedBucket {
|
||||
double value;
|
||||
double resolution;
|
||||
int64_t count;
|
||||
int64_t cumulative_count;
|
||||
};
|
||||
|
||||
struct RecordedSnapshot {
|
||||
std::vector<RecordedBucket> buckets;
|
||||
int64_t total_count = 0;
|
||||
};
|
||||
|
||||
using HistogramPointer = DeleteFnPtr<hdr_histogram, hdr_close>;
|
||||
|
||||
struct RecordedSnapshotSource {
|
||||
HistogramPointer histogram;
|
||||
std::shared_ptr<const RecordedSnapshot> snapshot;
|
||||
uint64_t generation = 0;
|
||||
bool cache_hit = false;
|
||||
};
|
||||
|
||||
// Factory method that returns nullptr on hdr_init failure.
|
||||
static std::shared_ptr<Histogram> Create(const Options& options);
|
||||
|
||||
@@ -129,6 +148,10 @@ class Histogram : public MemoryRetainer {
|
||||
void LogBuckets(int64_t first_bucket, double log_base, Iterator&& fn) const;
|
||||
|
||||
bool IsCompatible(const Histogram& other) const;
|
||||
RecordedSnapshotSource GetRecordedSnapshotSource(bool use_cache) const;
|
||||
static RecordedSnapshot BuildRecordedSnapshot(const hdr_histogram* histogram);
|
||||
void CacheRecordedSnapshot(uint64_t generation,
|
||||
std::shared_ptr<const RecordedSnapshot> snapshot);
|
||||
|
||||
void MemoryInfo(MemoryTracker* tracker) const override;
|
||||
SET_MEMORY_INFO_NAME(Histogram)
|
||||
@@ -136,6 +159,8 @@ class Histogram : public MemoryRetainer {
|
||||
|
||||
private:
|
||||
inline void UpdateEwma(double value);
|
||||
inline void InvalidateRecordedSnapshot();
|
||||
size_t GetCachedRecordedSnapshotMemorySize() const;
|
||||
|
||||
HistogramPointer histogram_;
|
||||
uint64_t prev_ = 0;
|
||||
@@ -151,6 +176,9 @@ class Histogram : public MemoryRetainer {
|
||||
int64_t threshold_ = 0;
|
||||
double ewma_error_rate_ = 0;
|
||||
|
||||
uint64_t mutation_generation_ = 0;
|
||||
std::shared_ptr<const RecordedSnapshot> recorded_snapshot_cache_;
|
||||
|
||||
RwLock mutex_;
|
||||
};
|
||||
|
||||
@@ -203,6 +231,7 @@ class HistogramImpl {
|
||||
static void GetCohensD(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
static void GetCliffsD(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
static void GetPercentileCI(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
static void GetQrde(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
static void GetEwmaMean(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
static void GetEwmaStddev(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
static void GetEwmaErrorRate(const v8::FunctionCallbackInfo<v8::Value>& args);
|
||||
|
||||
Vendored
+133
@@ -0,0 +1,133 @@
|
||||
# Regenerate qrde-r-oracle.json with:
|
||||
# Rscript test/fixtures/qrde-r-oracle.R > test/fixtures/qrde-r-oracle.json
|
||||
|
||||
beta_front <- function(x, a, b) {
|
||||
result <- numeric(length(x))
|
||||
interior <- x > 0 & x < 1
|
||||
result[interior] <- x[interior] * (1 - x[interior]) *
|
||||
dbeta(x[interior], a, b)
|
||||
result
|
||||
}
|
||||
|
||||
bucket_ranges <- function(values, resolutions, counts, mode) {
|
||||
lower <- upper <- values
|
||||
spread <- counts > 1 &
|
||||
(mode == "all" | (mode == "hdr" & resolutions > 1))
|
||||
half <- resolutions / 2
|
||||
lower[spread] <- values[spread] - half[spread]
|
||||
upper[spread] <- values[spread] + half[spread]
|
||||
if (length(values) > 1) {
|
||||
if (spread[1]) lower[1] <- values[1]
|
||||
if (spread[length(values)]) upper[length(values)] <- values[length(values)]
|
||||
}
|
||||
list(lower = lower, upper = upper, spread = spread)
|
||||
}
|
||||
|
||||
hd_quantile <- function(values, resolutions, counts, p, mode) {
|
||||
n <- sum(counts)
|
||||
a <- (n + 1) * p
|
||||
b <- (n + 1) * (1 - p)
|
||||
u1 <- cumsum(counts) / n
|
||||
u0 <- c(0, head(u1, -1))
|
||||
threshold <- (a + 1) / (a + b + 2)
|
||||
lower <- u1 <= threshold
|
||||
upper <- u0 >= threshold
|
||||
crossing <- !(lower | upper)
|
||||
mass <- numeric(length(values))
|
||||
mass[lower] <- pbeta(u1[lower], a, b) - pbeta(u0[lower], a, b)
|
||||
mass[upper] <- pbeta(1 - u0[upper], b, a) -
|
||||
pbeta(1 - u1[upper], b, a)
|
||||
mass[crossing] <- 1 - pbeta(u0[crossing], a, b) -
|
||||
pbeta(1 - u1[crossing], b, a)
|
||||
|
||||
ranges <- bucket_ranges(values, resolutions, counts, mode)
|
||||
if (!any(ranges$spread)) return(sum(values * mass))
|
||||
|
||||
front0 <- beta_front(u0, a, b)
|
||||
front1 <- beta_front(u1, a, b)
|
||||
local <- (p - u0) * mass - (front1 - front0) / (a + b)
|
||||
rank_width <- counts / n
|
||||
local <- pmax(0, pmin(rank_width * mass, local))
|
||||
sum(ifelse(ranges$spread,
|
||||
ranges$lower * mass +
|
||||
(ranges$upper - ranges$lower) * local / rank_width,
|
||||
values * mass))
|
||||
}
|
||||
|
||||
make_case <- function(name, values, resolutions, counts, bins, mode, indices) {
|
||||
ranges <- bucket_ranges(values, resolutions, counts, mode)
|
||||
expected <- vapply(indices, function(index) {
|
||||
if (index == 0) return(ranges$lower[1])
|
||||
if (index == bins) return(tail(ranges$upper, 1))
|
||||
hd_quantile(values, resolutions, counts, index / bins, mode)
|
||||
}, numeric(1))
|
||||
list(name = name, indices = indices, expected = expected)
|
||||
}
|
||||
|
||||
make_probability_case <- function(name, values, resolutions, counts,
|
||||
probabilities, mode) {
|
||||
ranges <- bucket_ranges(values, resolutions, counts, mode)
|
||||
expected <- vapply(probabilities, function(probability) {
|
||||
if (probability == 0) return(ranges$lower[1])
|
||||
if (probability == 1) return(tail(ranges$upper, 1))
|
||||
hd_quantile(values, resolutions, counts, probability, mode)
|
||||
}, numeric(1))
|
||||
list(name = name, probabilities = probabilities, expected = expected)
|
||||
}
|
||||
|
||||
cases <- list(
|
||||
make_case("small-none", c(1, 2, 4, 16, 100), rep(1, 5),
|
||||
c(1, 2, 5, 3, 1), 10, "none", 0:10),
|
||||
make_case("small-all", c(1, 2, 4, 16, 100), rep(1, 5),
|
||||
c(1, 2, 5, 3, 1), 10, "all", 0:10),
|
||||
make_case("exact-support", 1:1024, rep(1, 1024), rep(1, 1024),
|
||||
100, "none", c(1, 2, 10, 25, 50, 75, 90, 98, 99)),
|
||||
make_probability_case("tail-focused", 1:1024, rep(1, 1024), rep(1, 1024),
|
||||
c(0, 0.5, 0.9, 0.99, 0.999, 0.9999, 1), "none"),
|
||||
make_case("approximation-threshold", c(1, 131071), c(1, 1),
|
||||
c(26239, 973761), 1000, "none",
|
||||
c(1, 10, 24, 25, 26, 27, 50, 500, 950, 973, 974, 975, 976,
|
||||
990, 999)),
|
||||
make_case("multimodal-none", c(1, 10, 100, 1000), rep(1, 4),
|
||||
c(900000, 90000, 9000, 1000), 100, "none",
|
||||
c(1, 10, 25, 50, 75, 89, 90, 91, 98, 99)),
|
||||
make_case("multimodal-all", c(1, 10, 100, 1000), rep(1, 4),
|
||||
c(900000, 90000, 9000, 1000), 100, "all",
|
||||
c(1, 10, 25, 50, 75, 89, 90, 91, 98, 99)),
|
||||
make_case("wide-none",
|
||||
c(1049088, 1100048498688, 4505798650626048, 9005000231485440),
|
||||
c(1024, 1073741824, 4398046511104, 4398046511104),
|
||||
c(700000, 200000, 99999, 1), 1000, "none",
|
||||
c(1, 100, 500, 699, 700, 701, 899, 900, 901, 990, 999)),
|
||||
make_case("wide-hdr",
|
||||
c(1049088, 1100048498688, 4505798650626048, 9005000231485440),
|
||||
c(1024, 1073741824, 4398046511104, 4398046511104),
|
||||
c(700000, 200000, 99999, 1), 1000, "hdr",
|
||||
c(1, 100, 500, 699, 700, 701, 899, 900, 901, 990, 999)),
|
||||
make_case("huge-count", c(1, 1000), c(1, 1),
|
||||
c(2^50, 2 * 2^50), 3, "none", c(1, 2))
|
||||
)
|
||||
|
||||
number_array <- function(values) {
|
||||
paste0("[", paste(sprintf("%.17g", values), collapse = ", "), "]")
|
||||
}
|
||||
|
||||
cat("{\n")
|
||||
cat(sprintf(" \"rVersion\": \"%s\",\n", getRversion()))
|
||||
cat(" \"implementation\": \"stats::pbeta/stats::dbeta\",\n")
|
||||
cat(" \"generator\": \"test/fixtures/qrde-r-oracle.R\",\n")
|
||||
cat(" \"cases\": {\n")
|
||||
for (i in seq_along(cases)) {
|
||||
case <- cases[[i]]
|
||||
cat(sprintf(" \"%s\": {\n", case$name))
|
||||
if (is.null(case$indices)) {
|
||||
cat(sprintf(" \"probabilities\": %s,\n",
|
||||
number_array(case$probabilities)))
|
||||
} else {
|
||||
cat(sprintf(" \"indices\": %s,\n", number_array(case$indices)))
|
||||
}
|
||||
cat(sprintf(" \"quantiles\": %s\n", number_array(case$expected)))
|
||||
cat(sprintf(" }%s\n", if (i == length(cases)) "" else ","))
|
||||
}
|
||||
cat(" }\n")
|
||||
cat("}\n")
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"rVersion": "4.3.3",
|
||||
"implementation": "stats::pbeta/stats::dbeta",
|
||||
"generator": "test/fixtures/qrde-r-oracle.R",
|
||||
"cases": {
|
||||
"small-none": {
|
||||
"indices": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
"quantiles": [1, 1.5965008927561191, 2.4535177038411491, 3.2717431130126258, 4.0354421521907682, 5.2952901873257829, 7.993414878390019, 12.95756351062192, 25.2075453528239, 59.34721654185303, 100]
|
||||
},
|
||||
"small-all": {
|
||||
"indices": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
"quantiles": [1, 1.5164508130154255, 2.3591698112327482, 3.1776385959166684, 3.9790848992171997, 5.3180579814394306, 8.051827450668231, 13.007946863612533, 25.296537882899184, 59.450958793096312, 100]
|
||||
},
|
||||
"exact-support": {
|
||||
"indices": [1, 2, 10, 25, 50, 75, 90, 98, 99],
|
||||
"quantiles": [10.740000001653616, 20.979999999999997, 102.90000000000001, 256.5, 512.5, 768.5, 922.09999999999991, 1004.02, 1014.2599999983464]
|
||||
},
|
||||
"tail-focused": {
|
||||
"probabilities": [0, 0.5, 0.90000000000000002, 0.98999999999999999, 0.999, 0.99990000000000001, 1],
|
||||
"quantiles": [1, 512.5, 922.09999999999991, 1014.2599999983464, 1023.3985498813823, 1023.9671500705408, 1024]
|
||||
},
|
||||
"approximation-threshold": {
|
||||
"indices": [1, 10, 24, 25, 26, 27, 50, 500, 950, 973, 974, 975, 976, 990, 999],
|
||||
"quantiles": [1, 1, 1, 1.0000000003635539, 8767.6505315696159, 131070.85769690933, 131071, 131071, 131071, 131071, 131071, 131071, 131071, 131071, 131071]
|
||||
},
|
||||
"multimodal-none": {
|
||||
"indices": [1, 10, 25, 50, 75, 89, 90, 91, 98, 99],
|
||||
"quantiles": [1, 1, 1, 1, 1, 1, 5.5031915372522544, 10, 10, 55.117879933163145]
|
||||
},
|
||||
"multimodal-all": {
|
||||
"indices": [1, 10, 25, 50, 75, 89, 90, 91, 98, 99],
|
||||
"quantiles": [1.0055555555555555, 1.0555555555555556, 1.1388888888888888, 1.2777777777777777, 1.4166666666666665, 1.4944444444444445, 5.5041002375271573, 9.6111111111111107, 10.388888888888889, 55.120539546632884]
|
||||
},
|
||||
"wide-none": {
|
||||
"indices": [1, 100, 500, 699, 700, 701, 899, 900, 901, 990, 999],
|
||||
"quantiles": [1049088, 1049088, 1049088, 16044533331.731945, 550152461987.60876, 1084084847693.1234, 3073520372530.2637, 2255046784384464.5, 4503906146402988, 4505798650626048, 4505798650626048]
|
||||
},
|
||||
"wide-hdr": {
|
||||
"indices": [1, 100, 500, 699, 700, 701, 899, 900, 901, 990, 999],
|
||||
"quantiles": [1049088.7314285715, 1049161.142857143, 1049453.7142857143, 16036716594.329391, 549884945967.66187, 1083561149070.3787, 3073089774188.3379, 2253952024297723, 4501752029575391.5, 4507557908813304, 4507953736957585]
|
||||
},
|
||||
"huge-count": {
|
||||
"indices": [1, 2],
|
||||
"quantiles": [500.49999838367461, 1000]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use strict';
|
||||
|
||||
const common = require('../common');
|
||||
const fixtures = require('../common/fixtures');
|
||||
const assert = require('assert');
|
||||
const { createHistogram } = require('perf_hooks');
|
||||
|
||||
const oracle = JSON.parse(fixtures.readSync('qrde-r-oracle.json', 'utf8'));
|
||||
|
||||
function buildHistogram(entries, options = {}) {
|
||||
const histogram = createHistogram(options);
|
||||
for (const [value, count] of entries) {
|
||||
if (count === 1) {
|
||||
histogram.record(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = createHistogram(options);
|
||||
block.record(value);
|
||||
let remaining = count;
|
||||
while (remaining > 0) {
|
||||
if (remaining % 2 === 1) histogram.add(block);
|
||||
remaining = Math.floor(remaining / 2);
|
||||
if (remaining > 0) block.add(block);
|
||||
}
|
||||
}
|
||||
return histogram;
|
||||
}
|
||||
|
||||
function assertMatchesOracle(definition, result) {
|
||||
const expected = oracle.cases[definition.name];
|
||||
const span = result.quantiles.at(-1) - result.quantiles[0];
|
||||
const tolerance = Math.max(1e-10, span * definition.relativeTolerance);
|
||||
assert.strictEqual(result.bucketCount, definition.bucketCount);
|
||||
const indices = expected.indices ?? expected.probabilities.map((_, i) => i);
|
||||
if (expected.probabilities !== undefined) {
|
||||
assert.deepStrictEqual(result.probabilities,
|
||||
new Float64Array(expected.probabilities));
|
||||
}
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const index = indices[i];
|
||||
const difference = Math.abs(result.quantiles[index] -
|
||||
expected.quantiles[i]);
|
||||
assert.ok(difference <= tolerance,
|
||||
`${definition.name} p${result.probabilities[index]}: ` +
|
||||
`${result.quantiles[index]} != ${expected.quantiles[i]} ` +
|
||||
`(difference ${difference}, tolerance ${tolerance})`);
|
||||
}
|
||||
}
|
||||
|
||||
const exactTolerance = 2e-12;
|
||||
const asymptoticTolerance = 2e-8;
|
||||
const cases = [
|
||||
{
|
||||
name: 'small-none',
|
||||
bins: 10,
|
||||
dequantize: 'none',
|
||||
entries: [[1, 1], [2, 2], [4, 5], [16, 3], [100, 1]],
|
||||
bucketCount: 5,
|
||||
relativeTolerance: exactTolerance,
|
||||
},
|
||||
{
|
||||
name: 'small-all',
|
||||
bins: 10,
|
||||
dequantize: 'all',
|
||||
entries: [[1, 1], [2, 2], [4, 5], [16, 3], [100, 1]],
|
||||
bucketCount: 5,
|
||||
relativeTolerance: exactTolerance,
|
||||
},
|
||||
{
|
||||
name: 'exact-support',
|
||||
bins: 100,
|
||||
dequantize: 'none',
|
||||
entries: Array.from({ length: 1024 }, (_, index) => [index + 1, 1]),
|
||||
bucketCount: 1024,
|
||||
relativeTolerance: exactTolerance,
|
||||
},
|
||||
{
|
||||
name: 'tail-focused',
|
||||
probabilities: [0, 0.5, 0.9, 0.99, 0.999, 0.9999, 1],
|
||||
dequantize: 'none',
|
||||
entries: Array.from({ length: 1024 }, (_, index) => [index + 1, 1]),
|
||||
bucketCount: 1024,
|
||||
relativeTolerance: exactTolerance,
|
||||
},
|
||||
{
|
||||
name: 'approximation-threshold',
|
||||
bins: 1000,
|
||||
dequantize: 'none',
|
||||
entries: [[1, 26239], [131071, 973761]],
|
||||
options: { highest: 131071, figures: 5 },
|
||||
bucketCount: 2,
|
||||
relativeTolerance: asymptoticTolerance,
|
||||
},
|
||||
{
|
||||
name: 'multimodal-none',
|
||||
bins: 100,
|
||||
dequantize: 'none',
|
||||
entries: [[1, 900000], [10, 90000], [100, 9000], [1000, 1000]],
|
||||
bucketCount: 4,
|
||||
relativeTolerance: asymptoticTolerance,
|
||||
},
|
||||
{
|
||||
name: 'multimodal-all',
|
||||
bins: 100,
|
||||
dequantize: 'all',
|
||||
entries: [[1, 900000], [10, 90000], [100, 9000], [1000, 1000]],
|
||||
bucketCount: 4,
|
||||
relativeTolerance: asymptoticTolerance,
|
||||
},
|
||||
{
|
||||
name: 'wide-none',
|
||||
bins: 1000,
|
||||
dequantize: 'none',
|
||||
entries: [
|
||||
[2 ** 20, 700000],
|
||||
[2 ** 40, 200000],
|
||||
[2 ** 52, 99999],
|
||||
[Number.MAX_SAFE_INTEGER, 1],
|
||||
],
|
||||
bucketCount: 4,
|
||||
relativeTolerance: asymptoticTolerance,
|
||||
},
|
||||
{
|
||||
name: 'wide-hdr',
|
||||
bins: 1000,
|
||||
dequantize: 'hdr',
|
||||
entries: [
|
||||
[2 ** 20, 700000],
|
||||
[2 ** 40, 200000],
|
||||
[2 ** 52, 99999],
|
||||
[Number.MAX_SAFE_INTEGER, 1],
|
||||
],
|
||||
bucketCount: 4,
|
||||
relativeTolerance: asymptoticTolerance,
|
||||
},
|
||||
{
|
||||
name: 'huge-count',
|
||||
bins: 3,
|
||||
dequantize: 'none',
|
||||
entries: [[1, 2 ** 50], [1000, 2 ** 51]],
|
||||
bucketCount: 2,
|
||||
relativeTolerance: asymptoticTolerance,
|
||||
},
|
||||
];
|
||||
|
||||
(async () => {
|
||||
assert.strictEqual(oracle.implementation, 'stats::pbeta/stats::dbeta');
|
||||
for (const definition of cases) {
|
||||
const histogram = buildHistogram(definition.entries, definition.options);
|
||||
const options = { dequantize: definition.dequantize };
|
||||
if (definition.probabilities === undefined) options.bins = definition.bins;
|
||||
else options.probabilities = definition.probabilities;
|
||||
const result = await histogram.qrde(options);
|
||||
assertMatchesOracle(definition, result);
|
||||
}
|
||||
})().then(common.mustCall());
|
||||
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
const common = require('../common');
|
||||
const assert = require('assert');
|
||||
const { createHistogram } = require('perf_hooks');
|
||||
|
||||
function assertClose(actual, expected, tolerance = 1e-12) {
|
||||
assert.ok(Math.abs(actual - expected) <= tolerance,
|
||||
`${actual} != ${expected}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const empty = createHistogram();
|
||||
const emptyResult = await empty.qrde();
|
||||
assert.strictEqual(Object.getPrototypeOf(emptyResult), null);
|
||||
const defaultProbabilities = new Float64Array(101);
|
||||
for (let i = 0; i <= 100; i++) defaultProbabilities[i] = i / 100;
|
||||
assert.deepStrictEqual(emptyResult.probabilities, defaultProbabilities);
|
||||
assert.deepStrictEqual(emptyResult.quantiles, new Float64Array());
|
||||
assert.deepStrictEqual(emptyResult.densities, new Float64Array());
|
||||
assert.strictEqual(emptyResult.count, 0n);
|
||||
assert.strictEqual(emptyResult.bucketCount, 0);
|
||||
assert.strictEqual(emptyResult.corrections, 0);
|
||||
assert.strictEqual(emptyResult.dequantize, 'hdr');
|
||||
|
||||
assert.throws(() => empty.qrde(null), {
|
||||
code: 'ERR_INVALID_ARG_TYPE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ bins: 0 }), {
|
||||
code: 'ERR_OUT_OF_RANGE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ bins: 1001 }), {
|
||||
code: 'ERR_OUT_OF_RANGE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({
|
||||
bins: 10,
|
||||
probabilities: [0, 1],
|
||||
}), {
|
||||
code: 'ERR_INVALID_ARG_VALUE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ probabilities: [0] }), {
|
||||
code: 'ERR_INVALID_ARG_VALUE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ probabilities: [0.1, 1] }), {
|
||||
code: 'ERR_INVALID_ARG_VALUE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ probabilities: [0, 0.9] }), {
|
||||
code: 'ERR_INVALID_ARG_VALUE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ probabilities: [0, 0.5, 0.5, 1] }), {
|
||||
code: 'ERR_INVALID_ARG_VALUE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ probabilities: [0, 1.1, 1] }), {
|
||||
code: 'ERR_OUT_OF_RANGE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ probabilities: new Array(1002) }), {
|
||||
code: 'ERR_OUT_OF_RANGE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ dequantize: true }), {
|
||||
code: 'ERR_INVALID_ARG_VALUE',
|
||||
});
|
||||
assert.throws(() => empty.qrde({ cache: 1 }), {
|
||||
code: 'ERR_INVALID_ARG_TYPE',
|
||||
});
|
||||
|
||||
const sample = createHistogram();
|
||||
sample.record(3);
|
||||
sample.record(4);
|
||||
sample.record(7);
|
||||
const pending = sample.qrde({ bins: 10, dequantize: 'none' });
|
||||
assert.ok(pending instanceof Promise);
|
||||
const result = await pending;
|
||||
assert.ok(result.quantiles instanceof Float64Array);
|
||||
assert.ok(result.densities instanceof Float64Array);
|
||||
const tenBinProbabilities = new Float64Array(11);
|
||||
for (let i = 0; i <= 10; i++) tenBinProbabilities[i] = i / 10;
|
||||
assert.deepStrictEqual(result.probabilities, tenBinProbabilities);
|
||||
assert.strictEqual(result.quantiles.length, 11);
|
||||
assert.strictEqual(result.densities.length, 10);
|
||||
assert.strictEqual(result.count, 3n);
|
||||
assert.strictEqual(result.bucketCount, 3);
|
||||
assert.strictEqual(result.corrections, 0);
|
||||
assert.strictEqual(result.dequantize, 'none');
|
||||
assertClose(result.quantiles[5], 122 / 27);
|
||||
for (let i = 0; i < result.densities.length; i++) {
|
||||
const width = result.quantiles[i + 1] - result.quantiles[i];
|
||||
assertClose(result.densities[i] * width, 0.1);
|
||||
}
|
||||
|
||||
const customInput = [0, 0.2, 0.5, 0.9, 1];
|
||||
const customPending = sample.qrde({
|
||||
probabilities: customInput,
|
||||
dequantize: 'none',
|
||||
});
|
||||
customInput[1] = 0.4;
|
||||
const custom = await customPending;
|
||||
assert.deepStrictEqual(custom.probabilities,
|
||||
new Float64Array([0, 0.2, 0.5, 0.9, 1]));
|
||||
assert.strictEqual(custom.quantiles.length, 5);
|
||||
assert.strictEqual(custom.densities.length, 4);
|
||||
assertClose(custom.quantiles[1], result.quantiles[2]);
|
||||
assertClose(custom.quantiles[2], result.quantiles[5]);
|
||||
assertClose(custom.quantiles[3], result.quantiles[9]);
|
||||
for (let i = 0; i < custom.densities.length; i++) {
|
||||
const width = custom.quantiles[i + 1] - custom.quantiles[i];
|
||||
const mass = custom.probabilities[i + 1] - custom.probabilities[i];
|
||||
assertClose(custom.densities[i] * width, mass);
|
||||
}
|
||||
|
||||
const pointMass = createHistogram();
|
||||
for (let i = 0; i < 100; i++) pointMass.record(100);
|
||||
|
||||
const pointMassNone =
|
||||
await pointMass.qrde({ bins: 10, dequantize: 'none' });
|
||||
const pointMassHdr = await pointMass.qrde({ bins: 10 });
|
||||
assert.deepStrictEqual(pointMassNone.quantiles,
|
||||
new Float64Array(11).fill(100));
|
||||
assert.deepStrictEqual(pointMassHdr.quantiles, pointMassNone.quantiles);
|
||||
assert.ok(pointMassNone.densities.every((value) => value === Infinity));
|
||||
assert.ok(pointMassHdr.densities.every((value) => value === Infinity));
|
||||
|
||||
const pointMassAll =
|
||||
await pointMass.qrde({ bins: 10, dequantize: 'all' });
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
assertClose(pointMassAll.quantiles[i], 99.5 + i / 10);
|
||||
}
|
||||
for (const density of pointMassAll.densities) assertClose(density, 1);
|
||||
assert.strictEqual(pointMassAll.corrections, 0);
|
||||
|
||||
const wideBucket = createHistogram();
|
||||
for (let i = 0; i < 100; i++) wideBucket.record(100_000);
|
||||
const [wideNone, wideHdr, wideAll] = await Promise.all([
|
||||
wideBucket.qrde({ bins: 10, dequantize: 'none' }),
|
||||
wideBucket.qrde({ bins: 10, dequantize: 'hdr' }),
|
||||
wideBucket.qrde({ bins: 10, dequantize: 'all' }),
|
||||
]);
|
||||
assert.ok(wideNone.densities.every((value) => value === Infinity));
|
||||
assert.ok(wideHdr.densities.every(Number.isFinite));
|
||||
assert.deepStrictEqual(wideHdr.quantiles, wideAll.quantiles);
|
||||
|
||||
const snapshot = createHistogram();
|
||||
snapshot.record(3);
|
||||
snapshot.record(4);
|
||||
snapshot.record(7);
|
||||
const snapshotPending =
|
||||
snapshot.qrde({ bins: 1000, dequantize: 'none' });
|
||||
snapshot.record(1000);
|
||||
const snapshotResult = await snapshotPending;
|
||||
assert.strictEqual(snapshotResult.count, 3n);
|
||||
assert.strictEqual(snapshotResult.quantiles.at(-1), 7);
|
||||
|
||||
const cachedSnapshot = createHistogram();
|
||||
cachedSnapshot.record(3);
|
||||
cachedSnapshot.record(4);
|
||||
cachedSnapshot.record(7);
|
||||
const cachedPending = cachedSnapshot.qrde({ bins: 1000, cache: true });
|
||||
cachedSnapshot.record(1000);
|
||||
const cachedResult = await cachedPending;
|
||||
assert.strictEqual(cachedResult.count, 3n);
|
||||
assert.strictEqual(cachedResult.quantiles.at(-1), 7);
|
||||
const refreshedCache = await cachedSnapshot.qrde({
|
||||
probabilities: [0, 0.5, 1],
|
||||
cache: true,
|
||||
});
|
||||
assert.strictEqual(refreshedCache.count, 4n);
|
||||
assert.strictEqual(refreshedCache.quantiles.at(-1), 1000);
|
||||
const reusedCache = await cachedSnapshot.qrde({ bins: 4, cache: true });
|
||||
assert.strictEqual(reusedCache.count, 4n);
|
||||
assert.strictEqual(reusedCache.quantiles.at(-1), 1000);
|
||||
cachedSnapshot.reset();
|
||||
const invalidatedCache = await cachedSnapshot.qrde({ cache: true });
|
||||
assert.strictEqual(invalidatedCache.count, 0n);
|
||||
|
||||
const arithmeticCache = createHistogram();
|
||||
arithmeticCache.record(1);
|
||||
await arithmeticCache.qrde({ cache: true });
|
||||
const operand = createHistogram();
|
||||
operand.record(2);
|
||||
arithmeticCache.add(operand);
|
||||
assert.strictEqual(
|
||||
(await arithmeticCache.qrde({ cache: true })).count, 2n);
|
||||
arithmeticCache.subtract(operand);
|
||||
assert.strictEqual(
|
||||
(await arithmeticCache.qrde({ cache: true })).count, 1n);
|
||||
arithmeticCache.recordCorrected(10, 5);
|
||||
assert.strictEqual(
|
||||
(await arithmeticCache.qrde({ cache: true })).count, 3n);
|
||||
|
||||
const exactTails = createHistogram();
|
||||
for (let i = 1; i <= 1024; i++) exactTails.record(i);
|
||||
const [exactNone, exactAll] = await Promise.all([
|
||||
exactTails.qrde({ bins: 100, dequantize: 'none' }),
|
||||
exactTails.qrde({ bins: 100, dequantize: 'all' }),
|
||||
]);
|
||||
assert.strictEqual(exactNone.bucketCount, 1024);
|
||||
assertClose(exactNone.quantiles[1], 10.740000001653616, 1e-9);
|
||||
assertClose(exactNone.quantiles[99], 1014.2599999983464, 1e-9);
|
||||
assertClose(exactAll.quantiles[1], exactNone.quantiles[1]);
|
||||
assertClose(exactAll.quantiles[99], exactNone.quantiles[99]);
|
||||
|
||||
const tinyUpperTail = createHistogram({ highest: 32768, figures: 4 });
|
||||
for (let i = 1; i <= 31; i++) tinyUpperTail.record(i);
|
||||
tinyUpperTail.record(16384);
|
||||
const tinyUpperTailResult =
|
||||
await tinyUpperTail.qrde({ bins: 2, dequantize: 'none' });
|
||||
assertClose(tinyUpperTailResult.quantiles[1],
|
||||
16.500000000000938, 2e-13);
|
||||
|
||||
const largeCount = createHistogram();
|
||||
largeCount.record(1);
|
||||
largeCount.record(3);
|
||||
for (let i = 0; i < 52; i++) {
|
||||
const copy = createHistogram();
|
||||
copy.add(largeCount);
|
||||
largeCount.add(copy);
|
||||
}
|
||||
largeCount.record(1);
|
||||
const largeCountResult =
|
||||
await largeCount.qrde({ bins: 2, dequantize: 'none' });
|
||||
assert.strictEqual(largeCountResult.count, (1n << 53n) + 1n);
|
||||
assertClose(largeCountResult.quantiles[1], 2);
|
||||
})().then(common.mustCall());
|
||||
Reference in new issue
Block a user