Chio/Docs
LOGIN · JOIN

PlatformLoad & Egress

Node

Performance & Tuning

What each guard costs to run, what the receipt store costs to keep, and which four things actually bound a deployment.

Guard evaluation is CPU work in chio-guards and chio-wasm-guards. Persisting the decision is disk work in chio-store-sqlite. Above a few thousand requests a second the second one dominates, so sizing a deployment is a storage question before it is a throughput one.

The latency and throughput tables are estimates

The figures in the next two tables are illustrative order-of-magnitude estimates, not measurements from any specific build or benchmark. Treat them as operator-facing rules of thumb, not SLOs. Build a histogram per guard from chio_guard_eval_duration_seconds against the target deployment before sizing it. The receipt-store figures further down are a measurement, and say how they were taken.

Latency by guard

GuardOperationLatency classScaling
ForbiddenPathGuardPath normalization + glob match<1msO(n patterns)
EgressAllowlistGuardURL parse + glob match<1msO(n patterns)
ShellCommandGuardshlex tokenization + regex<2msO(n tokens × n patterns)
InternalNetworkGuardIP parse + CIDR membership<0.5msO(log CIDRs)
AgentVelocityGuardToken bucket update<0.1ms amortizedO(1)
DataFlowGuardSession journal sum<1msO(n history)
BehavioralSequenceGuardSequence pattern check<1msO(n window)
JailbreakGuard (cached)Cache hit on prompt hash<0.1msO(1)
JailbreakGuard (full)Heuristic + classifier eval20-50msO(prompt length)
ResponseSanitizationGuardRegex pass over response5-20msO(response size × n patterns)
WASM custom guardModule call within fuel limit10-100msFuel-dependent
AsyncGuardAdapter (cached)TtlCache hit, no provider call<0.5msO(1)
AsyncGuardAdapter (miss)Live HTTP to external provider100-500msNetwork-bound

The external-guard row spans three orders of magnitude between a hit and a miss, so the miss rate, not the hit cost, sets the tail. A longer TTL lowers the miss rate and a larger capacity lowers LRU eviction, and both trade against how stale a verdict may be when it admits a call. TtlCache takes a per-entry TTL on insert and evicts on expiry or at capacity, so the two knobs are set per deployment against that freshness budget rather than to a recommended number.

A WASM guard's number is a policy choice rather than a property of the module. Custom WASM guards shows chio guard bench reading the fuel a module actually burns on a fixed request, which is the floor the per-module ceiling has to clear.


Throughput by pipeline shape

Pipeline shapeTargetBound
The default pipeline, 7 guards~1000 req/s/coreCPU
Session-aware (with journal locks)~500 req/s/coreMutex contention
WASM custom guards100-1000 req/sFuel + module size
Async external (cache miss heavy)<100 req/sNetwork

These are per-process numbers. Horizontal scaling is the answer for traffic above 5K req/s, but it shifts the bottleneck from CPU to the receipt store. See Bottlenecks below.


What a receipt costs to keep

A row in chio_tool_receipts stores the canonical receipt JSON verbatim in raw_json alongside the columns the store projects out of it for querying, and every one of those projections that carries an index pays for it again in a b-tree. Receipt volume, not request volume, is what sizes the disk.

How this was measured

Two receipt stores, each written by 200 preflight chio check invocations against the starter policy chio init writes, every one of them admitted. The first sends a small parameter object; the second sends the same call plus one 2 KiB string, so the difference between the two rows is payload and nothing else. The script below reads the schema and the b-tree page totals out of the finished databases through SQLite's dbstat virtual table, so the bytes are the bytes on disk rather than an estimate of them.

receipt-footprint.pypython
import sqlite3

c = sqlite3.connect("file:small.db?mode=ro", uri=True)
names = [r[0] for r in c.execute(
    "SELECT name FROM sqlite_master WHERE type='index' "
    "AND tbl_name='chio_tool_receipts' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
auto = [r[0] for r in c.execute(
    "SELECT name FROM sqlite_master WHERE type='index' "
    "AND tbl_name='chio_tool_receipts' AND name LIKE 'sqlite_%' ORDER BY name")]
cols = [r[1] for r in c.execute("PRAGMA table_info(chio_tool_receipts)")]
c.close()
print("chio_tool_receipts: %d columns, %d named indexes, %d implicit"
      % (len(cols), len(names), len(auto)))
for n in names:
    print("  " + n)
print()
print("per receipt, averaged over each store:")
print("%-22s %8s %9s %8s %8s %7s" % ("parameter object", "receipts", "raw_json", "table", "indexes", "total"))
for label, path in (('{"name": "..."}', "small.db"), ("plus a 2 KiB string", "large.db")):
    c = sqlite3.connect("file:%s?mode=ro" % path, uri=True)
    n = c.execute("SELECT COUNT(*) FROM chio_tool_receipts").fetchone()[0]
    avg = c.execute("SELECT AVG(LENGTH(raw_json)) FROM chio_tool_receipts").fetchone()[0]
    tbl = c.execute("SELECT SUM(pgsize) FROM dbstat WHERE name = 'chio_tool_receipts'").fetchone()[0]
    idx = c.execute("SELECT SUM(pgsize) FROM dbstat WHERE name LIKE '%chio_tool_receipts%'"
                    " AND name <> 'chio_tool_receipts'").fetchone()[0]
    print("%-22s %8d %7.0f B %6.0f B %6.0f B %5.0f B"
          % (label, n, avg, tbl / n, idx / n, (tbl + idx) / n))
    c.close()

The result

receipt-store · footprinttranscript
$ python3 receipt-footprint.py
chio_tool_receipts: 16 columns, 9 named indexes, 1 implicit
  idx_chio_tool_receipts_capability
  idx_chio_tool_receipts_cost
  idx_chio_tool_receipts_cost_global
  idx_chio_tool_receipts_decision
  idx_chio_tool_receipts_grant
  idx_chio_tool_receipts_subject
  idx_chio_tool_receipts_tenant
  idx_chio_tool_receipts_timestamp
  idx_chio_tool_receipts_tool

per receipt, averaged over each store:
parameter object       receipts  raw_json    table  indexes   total
{"name": "..."}             200    2630 B   4116 B    532 B  4649 B
plus a 2 KiB string         200    4640 B   5140 B    532 B  5673 B
exit 0in my-agent

Every one of those 400 receipts is an allow. A canonical receipt for a two-field tool call runs about 2.6 KB and costs about 4.6 KB of store, roughly 1.8 times its own JSON. Two thousand bytes of extra parameters add about 2 KB of JSON and about 1 KB of store, which pulls the multiplier down to about 1.2. The overhead is not proportional, so the ratio improves as receipts grow.

The split explains it. The index column does not move between the two stores, because the nine named indexes and the implicit b-tree behind the UNIQUE on receipt_id all key on projected columns and none of them touches raw_json. What moves is the table, and it moves in whole SQLite pages, so a receipt smaller than a page still pays for the page. Six of those nine indexes are created at bootstrap and three more arrive through schema migration, which is why counting them from a live store beats counting them from any one source file.

For sizing, take the small-payload figure as the floor and scale by the parameter objects the deployment actually sends. A kernel sustaining 5K req/s writes 432 million receipts a day, which at that floor is about two terabytes a day before payload. RetentionConfig in chio-kernel is the control that bounds it, and receipt volume rather than request volume is what the disk is sized against.

This is one receipt shape on one machine

Every receipt above is a preflight decision on a two-field tool call with one guard profile. A receipt carrying guard evidence rows, a larger tool response, or cost fields is larger, and SQLite's page allocation quantizes small stores. Re-run the script against a store the deployment actually produced before committing to a disk budget.

The rest of the resident set

SubsystemBoundWhere it comes from
WASM linear memory16 MiB per instanceMAX_MEMORY_BYTES in chio-wasm-guards, overridable per guard through max_memory_bytes. Each evaluation gets a fresh store and instance that is dropped when the call returns.
WASM module on disk10 MiBmax_module_size, checked before compilation so an oversized binary never reaches Wasmtime.
External-guard verdict cache1024 entriesAsyncGuardAdapterConfig::cache_capacity. A bounded TtlCache of verdicts under whatever key the wrapped guard's cache_key returns; a guard that returns None is not cached at all.
Session journal4096 entries per sessionSessionJournal in chio-http-session keeps its entries in a bounded ring behind one Mutex, sized from MemoryBudgetConfig::journal_entry_cap rather than a local literal, so lowering the process budget lowers this. Evicted entries leave the ring but the counters the guards read stay cumulative.

Bottlenecks

Four bottlenecks dominate, in this order:

  1. Receipt store I/O. Every allowed or denied call writes one receipt. SQLite INSERT latency is the floor for kernel evaluation throughput on a single node. The store opens in WAL mode and refuses to run in any other journal mode, so that part is not a tuning decision; what is left is checkpoint batch size and per-tenant store sharding.
  2. Session journal locks. The session-aware guards, DataFlowGuard and BehavioralSequenceGuard, each hold an Arc<SessionJournal> and take that journal's Mutex per evaluation. High concurrency on the same session serializes. Mitigate by sharding sessions across journals or by batching low-stakes calls outside the session. The velocity guards are a separate case: VelocityGuard and AgentVelocityGuard hold no journal, only a guard-wide Mutex over their own token-bucket map (VelocityGuard keys buckets by (capability_id, grant_index)), so journal sharding does nothing for them. Relieving that contention means sharding the bucket map itself.
  3. WASM fuel. A WASM guard that exhausts its fuel returns Verdict::Deny, and the reason it carries classifies as reason_class = "fuel" on chio_guard_deny_total. Tail latency before that deny is the full fuel ceiling, so lowering the per-module limit lowers the tail as well as the blast radius.
  4. External guard circuit breaker. A degraded provider can stall a synchronous pipeline; the breaker prevents that but at the cost of dropping calls during the open window. Mitigate by tuning RetryConfig::max_retries and CircuitBreakerConfig::reset_timeout for your provider's actual SLA.

Tuning knobs

These knobs size a deployment so it stays inside its limits. The limits themselves, and what each one returns when a request crosses it, are Backpressure and limits.

  • Checkpoint cadence. checkpoint_interval in the receipts section (default 100 receipts per Merkle batch; it feeds the kernel's checkpoint_batch_size). Raise this to amortize signing cost; lower it for shorter recovery windows. 0 disables checkpointing.
  • Receipt retention. RetentionConfig::retention_days (default 90) and max_size_bytes (default 10 GB), with check_interval_secs (default one hour) setting how often rotation is evaluated. Rotation moves aged rows into the archive database named by archive_path, and checkpoint validation opens that archive on its own read-only connection, so a receipt stays verifiable against its Merkle root after it leaves the live store.
  • Session journal sharding. Shard by agent ID or session ID. Sharding by agent splits hot sessions across journals; pick the dimension that matches your contention pattern.
  • WASM fuel limits. Per-module ceiling, expressed in Wasmtime fuel units. Lower ceilings cut tail latency; raise them only when a module hits the ceiling on legitimate input.
  • AsyncGuardAdapter cache TTL. cache_ttl on AsyncGuardAdapterConfig, a Duration (default Duration::from_secs(60)). Bigger TTLs raise hit rate at the cost of evidence freshness.
  • AsyncGuardAdapter rate limit. rate_per_second and rate_burst (defaults 20 / 20). Sized to typical provider QPS budgets; raise after confirming your contract.

A worked example at 5K req/s

A six-replica horizontally-scaled fleet running the default pipeline, a policy-wired content-safety provider, and a custom WASM classifier. ~833 req/s per replica. Each key below is a supported ChioConfig field in chio-config. Every section is deny_unknown_fields, so a typo or an invented section fails to parse.

chio.yamlyaml
kernel:
  signing_key: "${CHIO_SIGNING_KEY}"

adapters:
  # At least one adapter is mandatory. An absent or empty adapters block
  # parses but fails validation with "at least one adapter is required".
  - id: petstore
    protocol: openapi
    upstream: "http://petstore.example/api"

receipts:
  store: "sqlite:///var/lib/chio/receipts.db"
  # Receipts between Merkle checkpoints. Larger batches amortize signing
  # cost; at 833 req/s/replica, 500 checkpoints about every 0.6s.
  checkpoint_interval: 500
  # Live retention window. Aged receipts move to a read-only archive on
  # rotation and stay verifiable against their checkpoint roots.
  retention_days: 30

logging:
  level: info
  format: json

telemetry:
  enabled: true
  endpoint: "http://otel-collector:4317"
  service_name: chio-edge

guards:
  # Force these guards to run on every request, regardless of route.
  required:
    - internal-network
    - agent-velocity

wasm_guards:
  # wasm_guards is a LIST of entries, each with its own fuel_limit.
  - name: content-classifier
    path: /etc/chio/guards/content-classifier/content_classifier.wasm
    fuel_limit: 5000000
    priority: 100

Settings outside chio.yaml

The content-safety provider is an external guard wired through a HushSpec policy, not a chio.yaml section (see External guards). Session-journal sharding and the deeper RetentionConfig fields (max_size_bytes, archive_path, check_interval_secs) have no key in the file schema and keep their Rust defaults. Of the retention fields, only retention_days is settable from chio.yaml.

Two things this configuration does not do:

  • It does not enable any CircuitOpenVerdict::Allow or RateLimitedVerdict::Allow fail-open paths. Those are reserved for advisory guards; the deny defaults remain in place.
  • It does not co-locate the receipt store with the agent. At 5K req/s, the SQLite receipt file is on the kernel's local disk; cross-replica receipt aggregation happens out-of-band via archive rotation or a streaming receipt sink.

Sharded receipt stores need careful checkpointing

Per-replica SQLite stores produce per-replica checkpoint chains. That is fine for audit, but operators who want one canonical per-tenant chain must consolidate either through a single-writer store or through the federated-evidence import path. Don't merge raw checkpoint records by hand; the chain links via previous_checkpoint_sha256 and a hand-merge corrupts the continuity proof.

See also