[
https://issues.apache.org/jira/browse/CASSANDRA-21667?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Jon Haddad updated CASSANDRA-21667:
-----------------------------------
Change Category: Performance
Complexity: Low Hanging Fruit
Component/s: Observability/Metrics
Status: Open (was: Triage Needed)
> Reduce allocation from metrics collection
> ------------------------------------------
>
> Key: CASSANDRA-21667
> URL: https://issues.apache.org/jira/browse/CASSANDRA-21667
> Project: Apache Cassandra
> Issue Type: Improvement
> Components: Observability/Metrics
> Reporter: Jon Haddad
> Assignee: Jon Haddad
> Priority: Normal
>
> The {{EstimatedPartitionCount}} table gauge recomputes its value from scratch
> every time it is read. Each read opens and deserialises the
> {{Statistics.db}} component of every live SSTable in the table, builds a
> HyperLogLog++ estimator from each one, and merges them. Nothing is cached.
> A metrics agent scrapes this gauge on every collection interval. {{nodetool
> tablestats}} reads it as well. The work is therefore repeated at the scrape
> rate, per table, for the life of the node.
> The inputs are immutable. An SSTable's cardinality estimator never changes
> after the file is written. The merged value can only change when the SSTable
> set changes.
> {{TableMetrics}} registers the gauge:
> {code:java}
> estimatedPartitionCount = createTableGauge("EstimatedPartitionCount",
> "EstimatedRowCount", new Gauge<Long>()
> {
> public Long getValue()
> {
> long memtablePartitions = 0;
> for (Memtable memtable : cfs.getTracker().getView().getAllMemtables())
> memtablePartitions += memtable.partitionCount();
> try(ColumnFamilyStore.RefViewFragment refViewFragment =
> cfs.selectAndReference(View.selectFunction(SSTableSet.CANONICAL)))
> {
> return
> SSTableReader.getApproximateKeyCount(refViewFragment.sstables) +
> memtablePartitions;
> }
> }
> }, null);
> {code}
> {{SSTableReader.getApproximateKeyCount}} then does this per SSTable:
> {code:java}
> CompactionMetadata metadata =
> StatsComponent.load(sstable.descriptor).compactionMetadata();
> ...
> if (cardinality == null)
> cardinality = metadata.cardinalityEstimator;
> else
> cardinality = cardinality.merge(metadata.cardinalityEstimator);
> {code}
> Three costs come out of those two lines.
> * {{StatsComponent.load(descriptor)}} passes {{MetadataType.values()}}. It
> deserialises {{VALIDATION}}, {{COMPACTION}}, {{STATS}} and {{HEADER}}. Only
> {{COMPACTION}} is used here. The other three are decoded and discarded.
> * Every call reopens the file. {{RandomAccessReader.open}} is called per
> SSTable, per scrape.
> * {{ICardinality.merge}} allocates a whole new estimator per SSTable and
> copies the running one into it.
> The per-table overhead was measured with JMH, {{AverageTime}} mode plus the
> {{gc}} profiler, on a table with 1000 partitions per SSTable and
> auto-compaction disabled. The benchmark reads the gauge and reports the cost
> of one call.
> ||SSTables in the table||Time per read||Heap allocated per read||
> |1|~49 us|~79 KiB|
> |10|~0.49 ms|~0.79 MiB|
> |100|4.9 ms|7.9 MiB|
> The cost is linear in the SSTable count. It does not depend on the partition
> count, because the estimator is a fixed-width structure once it leaves sparse
> mode.
> Per SSTable, per scrape: *~49 us of CPU and ~79 KiB of garbage*.
> That ~79 KiB divides into two parts.
> *Cardinality estimator: ~29 KiB per SSTable.* Measured separately with
> {{ThreadMXBean.getThreadAllocatedBytes}} over a deserialise-and-merge loop,
> at the precision Cassandra uses ({{new HyperLogLogPlus(13, 25)}}). A dense
> estimator at that precision serialises to 5473 bytes. Three copies of it
> exist per SSTable during the merge:
> * the {{byte[]}} read out of {{Statistics.db}}, ~5.5 KiB;
> * the register set of the deserialised estimator, {{int[1366]}}, ~5.5 KiB;
> * the fresh estimator that {{merge()}} allocates to hold the result, ~5.5 KiB.
> The rest is temporaries inside the deserialise and the merge.
> *The remaining metadata: ~50 KiB per SSTable.* This is the {{VALIDATION}},
> {{STATS}} and {{HEADER}} components that the caller never reads.
> {{StatsMetadata}} alone carries two {{EstimatedHistogram}} instances, a
> tombstone histogram, an {{IntervalSet}} of commit log positions, the covered
> clustering slice, the first and last keys and the encoding stats.
> {{SerializationHeader.Component}} carries the column maps. All of it is
> built and dropped.
> At the node level the arithmetic is {{tables x SSTables per table x 79 KiB /
> scrape interval}}.
> ||Node||Garbage per scrape||At a 10 s scrape interval||
> |100 tables, 20 SSTables each|~154 MiB|~15 MiB/s|
> |200 tables, 50 SSTables each|~790 MiB|~79 MiB/s|
> |500 tables, 50 SSTables each|~1.9 GiB|~193 MiB/s|
> This is short-lived garbage, so it lands in the young generation. It still
> sets a floor on the young collection rate that no workload asked for. On a
> node with many tables the scrape alone can dominate the allocation rate at
> idle.
> The CPU cost lands on the JMX or agent thread, not on a read path. The
> allocation cost lands on every thread, because it drives the collector.
> Two more per-scrape costs sit next to this one. Both read data that changes
> far less often than the scrape rate.
> * {{SecondaryIndexManager.getAllIndexColumnFamilyStores()}} builds a new
> {{HashSet}} and walks every index on every call. The backing tables only
> change when an index is registered or dropped. {{TableMetrics}} reads it per
> scrape, and {{ColumnFamilyStore.concatWithIndexes()}} reads it on the flush
> and compaction paths.
> * {{CompactionStrategyManager.getEstimatedRemainingTasks()}} calls
> {{getAllStrategies()}}, which builds a concat and a transform chain per call,
> to sum a handful of integers. It backs the {{PendingCompactions}} gauge.
> A per-table cache of the merged partition count should remove most of this.
> The value is a pure function of the SSTable set, so the cache holds until
> that set changes, and it must be invalidated whenever the SSTables change for
> any reason: flush, compaction, streaming, bulk load, index build, cleanup,
> secondary index rebuild, and any other lifecycle transaction. A cache that
> only watches compaction is wrong.
> With that in place a scrape reads a cached long instead of touching the file
> system, and the ~79 KiB per SSTable per scrape becomes ~79 KiB per SSTable
> set change.
> A JMH benchmark that measures both the cached and the uncached path is on the
> {{metrics-gauge-caching}} branch as {{EstimatedPartitionCountBench}}. It
> parameterises on the SSTable count.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]