Performance

Performance

Loading an EDB one fact at a time is correct but pays a fixed cost on every call: argument validation, predicate-kind and arity checks, capacity checks, and — across the WASM boundary — one string marshal per fact. Batch insertion amortizes that cost: the whole group is validated once, then inserted in a single core call. This page explains the batch APIs, their semantics, and the symbol-table index that makes interning cheap.

These APIs do not change what the engine computes — only how cheaply facts get into the EDB. The solve result is identical whether you insert facts one at a time or in a batch.

How batch insertion is built

Batch insertion stacks three mechanisms:

Symbol indexO(1) internID batchatomic insertString batchintern + insert
Symbol-table index   →  intern(string) is O(1) amortized, not O(n)
Symbol-id batch      →  insert N already-interned ids in one atomic call
Runtime string batch →  intern many strings, then insert them in one call

The runtime string batch is the composition of the other two: it interns each string through the symbol index, collects the resulting symbol ids into a bounded scratch, and hands them to the atomic id batch insert.

Why batch is cheaper

One validation

The whole batch is validated up front — global capacity, per-predicate capacity, and every value — before a single fact is written. Per-fact calls repeat that work N times.

O(1) interning

The symbol-table hash index turns string dedup lookup from a linear scan into an amortized constant-time probe. Re-interning a known string is a single hash + compare.

One boundary cross

Across WASM/JS, a string batch marshals one packed buffer with a single _malloc/_free, instead of one string marshal per fact.

The symbol-table index

Interning a string means mapping it to a stable integer id, reusing the same id if the string was seen before. The dedup check is backed by an internal open-addressing hash index, so the lookup is O(1) amortized rather than a linear scan of the symbol table.

i

The index is an internal accelerator only. It changes no observable behavior: symbol ids are still 1-based and assigned in first-insertion order, dedup is still exact, deep-copy and read-only paths are unchanged. The same program produces the same ids and the same solve result — just faster interning. There is no separate public API for it.

The index is a fixed-size internal array (zero malloc), so the engine stays bounded and deterministic. It is an accelerator over the canonical entry table, not a second source of truth — the entry table still defines id order.

Core batch APIs (C)

Two families exist: insert by already-interned symbol id, and insert from runtime strings.

By symbol id

Use this when you already hold symbol ids (for example, the same symbols inserted across many requests in a dense graph). Intern once, reuse the handles, insert in bulk.

maelys_datalog_edb_add_symbol_id_facts(edb, predicate, const symbol_id_t *values, size_t value_count)
edbOpen EDB.
predicateTarget EDB predicate (arity 1).
valuesArray of already-interned symbol ids.
value_countNumber of unary facts.
→ Returns: maelys_result_t — MAELYS_OK, or PAYLOAD_TOO_LARGE / INVALID_* on failure (no fact inserted).
Notes
  • Unary batch: one fact per id.
  • The whole batch is capacity- and value-validated before any fact is written (atomic).
maelys_datalog_edb_add_symbol_ids_facts(edb, predicate, const symbol_id_t *pairs, size_t pair_count)
edbOpen EDB.
predicateTarget EDB predicate (arity 2).
pairsFlat array: left0, right0, left1, right1, …
pair_countNumber of binary facts (the flat array holds 2 × pair_count ids).
→ Returns: maelys_result_t — MAELYS_OK, or PAYLOAD_TOO_LARGE / INVALID_* on failure (no fact inserted).
Notes
  • Binary batch: the array is flat pairs; pair_count counts facts, not ids.

From runtime strings

Use this for runtime values (user names, paths, request ids) that are not known at load time. The call interns each string through the symbol index, then inserts the resulting ids through the atomic id batch.

maelys_datalog_edb_add_runtime_symbol_facts(edb, predicate, const char *const *values, size_t value_count)
edbOpen EDB.
predicateTarget EDB predicate (arity 1).
valuesArray of UTF-8 strings.
value_countNumber of unary facts.
→ Returns: maelys_result_t — MAELYS_OK, or INVALID_ARGUMENT / PAYLOAD_TOO_LARGE on failure.
Notes
  • Interns each string, then inserts the batch once.
  • A NULL element returns INVALID_ARGUMENT; no EDB fact is inserted.
maelys_datalog_edb_add_runtime_symbol_pair_facts(edb, predicate, const char *const *flat_pairs, size_t pair_count)
edbOpen EDB.
predicateTarget EDB predicate (arity 2).
flat_pairsFlat array of strings: left0, right0, left1, right1, …
pair_countNumber of binary facts (the flat array holds 2 × pair_count strings).
→ Returns: maelys_result_t — MAELYS_OK, or INVALID_ARGUMENT / PAYLOAD_TOO_LARGE on failure.
Notes
  • Binary string batch: flat pairs, pair_count counts facts.

Semantics — read this before relying on batch

The batch path has a precise, deliberately partial atomicity model. Getting this right matters for correctness.

!

EDB insertion is atomic. Symbol interning is monotonic and is not rolled back.

The EDB write is all-or-nothing: if the batch cannot fit (capacity) or any value is invalid, zero EDB facts are inserted. But interning a string mutates the shared symbol table, and that mutation is never undone. If a string batch interns several strings and then fails (for example a later element is NULL, or the EDB is at capacity), no EDB fact is inserted, yet the strings interned earlier in that call may remain in the symbol table.

This is intentional and safe:

symbol interning is monotonic   — ids are assigned once
symbol ids are never reused      — a freed string would corrupt existing ids
no symbol-table rollback         — undoing an intern is never attempted

Why no rollback: the symbol index uses open addressing, where removing an entry would break the probe chain for every other string that collided through that slot. So the engine never removes a symbol. A resident-but-unused symbol is harmless; trying to reclaim it would not be.

Do not treat a batch call as a single transaction over both the symbol table and the EDB. It is not. The EDB write is transactional; the interning is monotonic. Plan for the case where a failed batch leaves new symbols interned but inserts no facts.

WASM / JS

The same batch families are exposed to JavaScript through MaelysPlayground. The string batch marshals one packed NUL-terminated UTF-8 buffer across the boundary with a single _malloc/_free. See WASM bindings — batch insertion for the JavaScript methods, the packed-buffer format, and the boundary validation (non-string and odd-length inputs are rejected before any WASM call).

The WASM boundary validates and marshals only. It adds no rollback and no new semantics: atomicity and monotonic interning are exactly the core behavior described above.

When to batch

Batch when:
  - you insert many facts for the same predicate in one evaluation
  - the same symbols recur across requests (intern once, reuse ids)
  - you cross the WASM boundary with many runtime strings (packed buffer)
 
Per-fact is fine when:
  - you add a handful of facts of mixed predicates/arities
  - you build multi-arity or mixed-type facts (use edb_add_fact)

See Runtime EDB — Batch insertion for the core API in the context of the full EDB lifecycle.

Summary

symbol index   →  intern O(1) amortized, no observable change
id batch       →  add_symbol_id_facts / add_symbol_ids_facts (atomic EDB)
string batch   →  add_runtime_symbol_facts / add_runtime_symbol_pair_facts
                  (intern via the symbol index → insert via the id batch)
semantics      →  EDB atomic · interning monotonic · no symbol-table rollback
WASM boundary  →  validates + marshals only, no new semantics

Measured performance

i

These are machine-dependent native microbenchmarks for regression tracking. They are not universal performance guarantees, and they do not include WASM, JavaScript, browser, or network costs. The page displays results captured by the benchmark harness for the engine commit and machine shown in the metadata.

How to read these charts

The charts answer three separate questions. Use the SMALL/LARGE selector to switch capacity profiles: SMALL is the default engine profile, LARGE is an opt-in profile with higher EDB and per-predicate limits (full limits are listed under Engine profiles below). The profile changes the capacity limits used by the benchmark. It is most visible in the solving charts, where the fixed size and size range depend on the profile. The loading and interning charts exercise the same engine code paths in both profiles, though the largest measured sizes may differ.

Switching to LARGE generally shifts the solving curves up and to the right. That is expected, not a slowdown in the engine: the selectivity and EDB-size charts simply test a bigger database under LARGE, and even when the data is identical (repeated solve) LARGE sizes its internal buffers for more capacity, so each solve touches more memory. Compare like with like — read each profile on its own rather than reading the jump from SMALL to LARGE as a regression.

  1. Does batching reduce loading overhead? The loading charts insert the same facts one at a time (unit_*) versus in a single batched call (batch_*). Compare the two lines at the same input size; lower median time is better.
  2. How does solve time change when more of the data matches? The selectivity chart keeps the total fact count fixed and varies only the fraction of facts that satisfy the rule. The x-axis is that matching fraction.
  3. How does solve time change when the database grows? The EDB size chart keeps the matching fraction near 10% and varies only the total number of loaded facts. The x-axis is that total.

Both solving charts use the same simple rule, allow(U) :- user(U), with unrelated noise facts stored under other predicates.

These are single-factor measurements: each solving chart changes one benchmark input at a time, making the trend easier to interpret instead of mixing "more matched" and "bigger database" in the same curve. They isolate one effect on this build and machine, not a universal law.

Loading performance measurements...

Methodology

These are native, hot-cache / warm-pool microbenchmarks:

  • Native only. The engine is compiled and timed as a C binary. WASM and JavaScript boundary costs are not included.
  • Warmup before timing. Each scenario runs through a warmup phase before the measured window, reducing one-off startup effects.
  • Median shown. Each case is measured many times (1000 runs here). The charts plot the median — sort all the runs from fastest to slowest and take the middle one, so half were faster and half slower. "p50" is just another name for it (the value at the 50th percentile). The median is used because it reflects a typical run and ignores the occasional outlier: on a shared machine the OS sometimes stalls a run, which inflates the slowest times but not the middle one. The raw artifacts also carry p95 (the value 95% of runs come in under — the slow tail), min, and max for anyone who wants to study that tail; it is left off the charts because it is far noisier from run to run and less useful for spotting regressions.
  • Production optimization. The charts use -O2, the level a production build uses.
  • Captured once. The page does not run benchmarks in your browser; it displays JSON artifacts produced for the engine commit and machine shown above each chart.

Relative comparisons are more useful for regression tracking than absolute times, but they remain environment-dependent: CPU, cache, operating system, and compiler all matter.

Engine profiles

The engine uses fixed-size storage. The selected profile changes these compile-time capacity limits; it does not change the Datalog semantics or the solve result.

The table lists public observable build limits. Internal scratch buffers are intentionally omitted because they are implementation details rather than observable engine capacities.

LimitSMALL (default)LARGE (opt-in)
Max facts per predicate64256
Max facts in the EDB10242048
Max derived facts (IDB)10242048
Max symbols512512
Max predicates128128
Max rules128128