Performance Tuning

pg_ripple performance depends on three interacting subsystems: the query engine, the write path, and the dictionary cache. This page provides diagnostic steps and tuning recipes for each bottleneck area, with realistic numbers from BSBM benchmarks and internal testing.


The Three Bottleneck Areas

┌──────────────────────────────────────────────────────┐
│                   Performance                         │
│                                                      │
│   ┌──────────┐   ┌──────────┐   ┌──────────────┐    │
│   │  Query    │   │  Write   │   │  Cache       │    │
│   │  Engine   │   │  Path    │   │  Pressure    │    │
│   │          │   │          │   │              │    │
│   │ Slow     │   │ Merge    │   │ Dictionary   │    │
│   │ SPARQL   │   │ worker   │   │ misses →     │    │
│   │ queries  │   │ lag,     │   │ table        │    │
│   │          │   │ delta    │   │ lookups      │    │
│   │          │   │ bloat    │   │              │    │
│   └──────────┘   └──────────┘   └──────────────┘    │
└──────────────────────────────────────────────────────┘

Diagnostic Workflow

Before tuning, identify which subsystem is the bottleneck:

-- Step 1: Overall health
SELECT pg_ripple.canary();

-- Step 2: Cache hit rate
SELECT
    (s->>'encode_cache_hits')::bigint AS hits,
    (s->>'encode_cache_misses')::bigint AS misses,
    ROUND(
        (s->>'encode_cache_hits')::numeric /
        NULLIF((s->>'encode_cache_hits')::numeric + (s->>'encode_cache_misses')::numeric, 0),
        4
    ) AS hit_rate
FROM pg_ripple.stats() s;

-- Step 3: Delta accumulation
SELECT (pg_ripple.stats()->>'unmerged_delta_rows')::int AS delta_rows;

-- Step 4: Slowest queries
SELECT calls, mean_exec_time::numeric(10,2) AS avg_ms, LEFT(query, 100)
FROM pg_stat_statements
WHERE query LIKE '%_pg_ripple.vp_%'
ORDER BY mean_exec_time DESC
LIMIT 10;
SymptomLikely BottleneckSection
High mean_exec_time on VP queriesQuery engineQuery Performance
delta_rows growing unboundedWrite path / mergeWrite Throughput
Cache hit rate < 95%Dictionary cacheCache Pressure
merge_worker_pid = 0Merge worker not runningWrite Throughput

Query Performance

Typical Performance Numbers

Based on BSBM benchmarks and internal testing with 10M triples on a 4-core/16GB instance:

Query PatternTypical LatencyNotes
Simple triple pattern (1 BGP)0.5–2msSingle VP table scan with B-tree
Star pattern (3–5 joins, same subject)2–10msSelf-join elimination reduces to 1 scan + joins
Path query (3 hops)5–20msWITH RECURSIVE, bounded depth
Complex BGP (5–8 patterns)10–50msBenefits from bgp_reorder
Aggregation (COUNT/SUM over 100K rows)20–80msPostgreSQL native aggregation
DESCRIBE (CBD, 50 outgoing arcs)5–15msDepends on describe_strategy
Federation (1 SERVICE call)50–500msNetwork-dominated

Tuning: Slow Single Queries

Step 1: Get the EXPLAIN output

SELECT pg_ripple.explain_sparql(
    'SELECT ?name WHERE {
        ?person <http://schema.org/knows> ?friend .
        ?friend <http://schema.org/name> ?name
    }',
    'text'
);

Step 2: Check for common issues

EXPLAIN PatternProblemFix
Seq Scan on vp_rarePredicate below promotion thresholdLower vp_promotion_threshold or load more data
Nested Loop with millions of rowsPoor join orderVerify bgp_reorder = on; run ANALYZE on VP tables
Sort + Unique on large resultUnnecessary DISTINCTAdd SHACL sh:maxCount 1 for functional predicates
CTE Scan with high loopsUnbounded property pathLower max_path_depth; add FILTER bounds
Hash Join with large build sideJoin on a high-cardinality predicateRewrite query to filter the large predicate first

Step 3: Enable the plan cache

-- Cache compiled SQL for repeated queries
SET pg_ripple.plan_cache_size = 512;

The plan cache eliminates parse/optimize/generate overhead for repeated SPARQL patterns. With BSBM's mix of 12 query templates, a cache size of 256 achieves ~98% hit rate.

Tuning: Overall Query Throughput

For workloads with many concurrent queries:

# Enable parallel query for complex joins
pg_ripple.parallel_query_min_joins = 2

# PostgreSQL parallel execution
max_parallel_workers_per_gather = 4
max_parallel_workers = 8

# Larger work_mem for complex joins
work_mem = '128MB'

BGP reordering impact

On a 10M triple dataset with 5-pattern BGPs, enabling bgp_reorder reduces median query time from 45ms to 12ms — a 3.7x improvement. Always keep this on unless you have a specific reason to disable it.


Write Throughput

Typical Write Performance

OperationThroughputNotes
insert_triple() (single)5,000–15,000 triples/secPer-backend, includes dictionary encoding
load_turtle() (bulk, inline)30,000–80,000 triples/secBatch dictionary encoding
load_turtle_file() (bulk, file)50,000–120,000 triples/secStreaming from disk, larger batches
sparql_update() INSERT DATA10,000–30,000 triples/secSPARQL parse overhead

Tuning: Merge Worker Lag

If unmerged_delta_rows grows continuously, the merge worker cannot keep up with the write rate.

Diagnosis:

-- Check delta accumulation
SELECT (pg_ripple.stats()->>'unmerged_delta_rows')::int AS delta;
-- Run again 60 seconds later — if delta is growing, merges are lagging

Solutions (in order of impact):

  1. Lower merge_threshold — Merge smaller batches more frequently:

    ALTER SYSTEM SET pg_ripple.merge_threshold = 5000;
    SELECT pg_reload_conf();
    
  2. Increase merge frequency — Reduce polling interval:

    ALTER SYSTEM SET pg_ripple.merge_interval_secs = 15;
    SELECT pg_reload_conf();
    
  3. Manual compaction — Force an immediate merge:

    SELECT pg_ripple.compact();
    
  4. Separate write windows — Batch writes during off-peak hours, then compact.

Tuning: Bulk Load Performance

For large initial data loads:

-- Temporarily disable SHACL validation
SET pg_ripple.shacl_mode = 'off';

-- Use file-based loading for best throughput
SELECT pg_ripple.load_turtle_file('/data/large_dataset.ttl');

-- Re-enable validation
SET pg_ripple.shacl_mode = 'async';

-- Force merge to move data to main tables
SELECT pg_ripple.compact();

Cache back-pressure

During bulk loads, pg_ripple monitors cache utilization against cache_budget. When utilization exceeds 90%, batch sizes are automatically reduced to prevent out-of-memory conditions. If you see slower-than-expected bulk loads, check encode_cache_utilization_pct in stats().


Cache Pressure

Diagnosis

SELECT
    (s->>'encode_cache_capacity')::int AS capacity,
    (s->>'encode_cache_utilization_pct')::int AS util_pct,
    (s->>'encode_cache_hits')::bigint AS hits,
    (s->>'encode_cache_misses')::bigint AS misses,
    (s->>'encode_cache_evictions')::bigint AS evictions
FROM pg_ripple.stats() s;
MetricHealthyAction Needed
Hit rate > 95%Normal operationNone
Hit rate 90–95%MarginalConsider increasing cache
Hit rate < 90%Cache thrashingIncrease dictionary_cache_size
Utilization > 90%Near-fullIncrease cache_budget
Evictions > 10% of hitsHigh churnWorking set exceeds cache

Sizing the Dictionary Cache

Rule of thumb: the cache should hold at least 80% of your unique terms.

-- Count unique terms
SELECT count(*) AS unique_terms FROM _pg_ripple.dictionary;
Unique TermsRecommended dictionary_cache_sizeMemory (approx.)
< 50K8,192~2 MB
50K – 500K65,536~13 MB
500K – 5M262,144~50 MB
5M – 50M500,000~100 MB
> 50M1,000,000 (max)~200 MB

Restart required

Changing dictionary_cache_size requires a PostgreSQL restart because shared memory is allocated at postmaster start. Plan your cache sizing during initial deployment.


Workload-Specific Recipes

Read-Heavy Analytics

Optimized for complex SPARQL queries with rare writes:

# Large plan cache for diverse query shapes
pg_ripple.plan_cache_size = 2048

# BGP optimization
pg_ripple.bgp_reorder = on
pg_ripple.parallel_query_min_joins = 2

# Large dictionary cache
pg_ripple.dictionary_cache_size = 262144
pg_ripple.cache_budget = 256

# Infrequent merges (writes are rare)
pg_ripple.merge_threshold = 100000
pg_ripple.merge_interval_secs = 300

# PostgreSQL
shared_buffers = '4GB'
effective_cache_size = '12GB'
work_mem = '256MB'
random_page_cost = 1.1

Expected: P95 query latency < 50ms for 5-pattern BGPs on 10M triples.

Write-Heavy Ingestion

Optimized for continuous data ingestion with periodic queries:

# Smaller plan cache (fewer distinct queries)
pg_ripple.plan_cache_size = 64

# Aggressive merging to keep delta small
pg_ripple.merge_threshold = 5000
pg_ripple.merge_interval_secs = 10
pg_ripple.latch_trigger_threshold = 2000
pg_ripple.auto_analyze = on

# Large cache to handle encoding pressure
pg_ripple.dictionary_cache_size = 500000
pg_ripple.cache_budget = 512

# Disable SHACL during ingestion
pg_ripple.shacl_mode = 'off'

# PostgreSQL — optimize for writes
shared_buffers = '2GB'
wal_buffers = '64MB'
checkpoint_completion_target = 0.9
max_wal_size = '4GB'

Expected: Sustained ingestion at 50K+ triples/sec with merge lag < 30 seconds.

Mixed HTAP (Read + Write)

Balanced for concurrent queries and writes:

# Moderate plan cache
pg_ripple.plan_cache_size = 512

# Balanced merge — not too frequent, not too lazy
pg_ripple.merge_threshold = 25000
pg_ripple.merge_interval_secs = 30
pg_ripple.latch_trigger_threshold = 10000
pg_ripple.auto_analyze = on

# Good cache coverage
pg_ripple.dictionary_cache_size = 131072
pg_ripple.cache_budget = 128

# Async SHACL so writes are not blocked
pg_ripple.shacl_mode = 'async'

# BGP optimization for read queries
pg_ripple.bgp_reorder = on

# PostgreSQL
shared_buffers = '4GB'
effective_cache_size = '12GB'
work_mem = '128MB'
max_parallel_workers_per_gather = 2

Expected: Read P95 < 30ms, write throughput > 20K triples/sec, merge lag < 60 seconds.


Benchmarking Your Deployment

Use the built-in compact() function and pg_stat_statements to establish baselines:

-- Reset statistics
SELECT pg_stat_statements_reset();

-- Run your workload (queries, inserts, etc.)

-- Collect results
SELECT
    calls,
    mean_exec_time::numeric(10,2) AS avg_ms,
    stddev_exec_time::numeric(10,2) AS stddev_ms,
    min_exec_time::numeric(10,2) AS min_ms,
    max_exec_time::numeric(10,2) AS max_ms,
    rows,
    LEFT(query, 80) AS query_prefix
FROM pg_stat_statements
WHERE query LIKE '%_pg_ripple%'
ORDER BY total_exec_time DESC
LIMIT 20;

Iterative tuning

Change one parameter at a time, re-run your benchmark, and compare. The most impactful parameters in order are:

  1. dictionary_cache_size (cache hit rate)
  2. bgp_reorder (query planning)
  3. merge_threshold (read freshness vs. write throughput)
  4. plan_cache_size (repeated query overhead)
  5. PostgreSQL work_mem (complex join performance)