GizStack
View .mdOpen in ChatGPTOpen in Claude

Performance

Goal

Gizlang code must be faster than Rust where this costs no safety. Overflow, bounds and stack checks stay. Bun is the minimum, and nginx is the reference for serving.

This page tells how to measure performance, and gives the rules that the measurements taught. Each result number has one owner: the published file of its suite, bench/results/AREA/SUITE.json (published results). The site, the roadmap and other documents read it or link to it, and do not copy it.

Measurement

Instructions for each unit of work

Instructions under callgrind are the stable measure on a shared machine. For a request, use:

sh
bin/giz bench/web/http/run.giz instructions PORT COMMAND...

Measure the margin, never a total over a short run. Make two runs of different lengths and subtract them, so that the startup cost is not divided among the requests. A total once gave nginx 6,974 instructions for each request. The real cost is 596.

CPU time for each unit

Read user and system time from /proc/PID/stat on one pinned worker. This shows where time goes when the instruction counts agree. For example, nginx uses approximately 330 ns of user time for each request, and Gizlang uses 650 ns. Both use 1 to 3 µs in the kernel.

Wall-clock rates only confirm this measure. Take the best of several runs, alternated with the rival at the same time, on pinned and separate CPUs. The load generator must not be the bottleneck (bench/web/lib/load.c, epoll).

System calls for each unit

Use strace -c. A keep-alive request costs one read and one write, as in nginx. Kernel time is noise for the language. Extra calls are not noise.

Profile by owner

Names: sudo -n perf record -g (a build host and backup have perf_event_paranoid 4), then perf report --stdio --sort symbol | gizlang tools/perf/names.giz BIN names each gizlang_methodx<H>, gx, derivedx and unsharex symbol from the binary's own gizlang_framex<H> string; no special build.

  1. Find the self cost of each function.
  2. Find the callers of the top entries.
  3. Fix the largest owner first.
  4. If a change does not decrease the count, revert it, however probable it seems. Examples: an nsw hint that LLVM had already inferred, and a direct read whose data was present for 0.1% of the time.

Rules

Each rule removed measured work from the HTTP path: from 23,200 instructions for each request to 8,776 (2026-10-22, bench/web/http/run.giz instructions, one worker). On one core, nginx uses 6,709 user instructions and the kernel 30,253; Gizlang serves 2.5 to 4% more requests (bench/web/http README). Each rule applies to all code that moves data.

  1. Copy one time. Data goes from the kernel into the bytes of the program in one copy. Parse in place and keep positions. Cut a string only for a value that the caller receives (a method, a target, a header name and value). Transform in place when the source is yours (header names are changed to lower case in the read buffer). Compare in place (the HTTP version is checked in the buffer, never cut).
  2. No work for the absent case. Use one scan for a set of bytes (bytes.indexOfAny), not one scan for each byte. Equality compares the length before it reads bytes. Trimming is a walk of an index, not a new string.
  3. Try the common case first, and measure it. A write goes directly to the socket (uv_try_write). Only a remainder goes into the queue. Thus nearly every response needs no copy, operation or task switch. Keep a fast path only when its measured hit rate is high.
  4. Do not wrap hot results. Result<Option<T>> makes and drops two layers for each call. When a type has a value that cannot occur (an empty start line, an empty method), use that value as the "nothing here" answer.
  5. Allocate rarely, and from size classes. Runtime and program buffers come from size classes with free lists for each thread (runtime/memory.c). The capacity of a buffer is its whole class, so appends fill it in place before it grows. A connection uses its read buffer again. A response is built in one buffer and written one time. A thread gives back its memory before it waits (gizlang_memory_idle): the blocks other threads freed for it, its empty pages, and glibc's freed large blocks. Before, a KV server held 212 MB of pages for 19 MB of blocks.
  6. Borrow by default. A read of a value takes no reference. A binding of a place borrows when nothing writes the place while the binding lives. Only consumers (a stored field, an element, a returned value) take a reference.
  7. Tell the optimizer what the language guarantees, and do not check it again.
    • The status is clear on entry, and no other pointer reaches it (llvm.assume, noalias). Thus status checks on paths without calls fold away.
    • A function outside every call cycle does not check the stack, because recursion always goes through a function that does (7% of a request). A function on a cycle compares the address of its frame with the floor of the stack. It does not store a counter on entry and restore it on each exit (fib is 22% faster).
    • Failure branches are marked unlikely (llvm.expect). Without the mark, each check halved the estimated frequency of the code after it. After a few checks, small calls looked cold and were not inlined. With the mark, gzip used 17% fewer instructions.
    • After its overflow check, the result of an operation is add nsw (nuw for unsigned), not the field of the intrinsic. Thus ranges flow through it, and the bound of a contract removes the index checks that it implies.
  8. Build constants one time. A Vec literal of constants is an immortal global, as a string literal is. Common methods and header names are interned. Thus tables and defaults cost nothing for each call, and they compare by address.
  9. Let the optimizer see everything. A process is one module whose functions are internal. Thus small functions and functions with one call are inlined. A runtime operation is divided into a small inline common case and an out-of-line remainder (concat: owned, with space, not an alias; push: owned, with space. This decreased the fill of a table from 28 instructions for each element).
  10. Use the width of the hardware. Byte scans compare 16 bytes at a time (SSE2). They never read past the end: the last bytes go through a block of zeros. A check that runs on every call (thread ownership) is a comparison of an address, not a library call.
  11. Keep the event loop quiet. A socket stays registered between reads. TCP_NODELAY is set. Workers share a port with SO_REUSEPORT (GIZLANG_WORKERS). The backlog is SOMAXCONN.
  12. Make the work as large as the input. A table for each call is as large as the input needs, not the maximum of the format. Deflate cleared 512 KB of hash tables for a 2 KB response and was ten times slower. Append in order what is indexed by position. Do not fill it first.
  13. Serialize typed values field by field. Data read at startup becomes records. A response appends the bytes of each field (writeString, writeNumber, integers as digits). It does not build and walk a generic tree for each request (HttpArena JSON: 6.4 times faster). Integers are written with division by the constant 10 (a multiplication), lowest digit first. A byte loop checks a short string for escapes: a C set scan built its table for each call.
  14. Pay for ownership only when it is shared (LangArena). The release on each drop and the uniqueness check before each element write are inlined (count 1: nothing to do). Free of a last reference and copy of a shared buffer are out-of-line calls (Calculator 4 times faster, NeuralNet 22% fewer instructions). A call to a function that cannot fail (no fail, io or console effect, outside every call cycle) has no status check after it. A mut parameter is noalias, so its fields stay in registers across stores.
  15. Keep small blocks together. Blocks of a maximum of 1 KiB come from 64 KiB pages of one size class, each with its own free list (the sharding of mimalloc). When blocks came from one list in free order, a tree built after many unrelated frees was 2 to 4 times slower.
  16. Hash one word at a time, and look up one time. Strings and bytes hash eight bytes for each multiplication (the folded 128-bit product of wyhash, mul_high). A hash of one byte at a time waits on its multiplication. Map.merge updates or inserts in one probe (the entry of Rust). When the table grows, only the slot array is built again (word counts 23%, n-grams 55% fewer instructions).
  17. Use threads for independent work. Task.parallel(n, body) runs on a pool of process threads, one for each CPU. The body can only compute and fail. What it captures is marked shared (the multi-threaded marking of Lean 4: counts become atomic one time, and the walk visits only what is not shared yet).
    • 200 parallel queries over a captured Vec of one million rows take 3 ms, not 542 ms for a freeze and thaw on each call.
    • Matmul at n=600 takes approximately the same time on 1 thread, and less on 4 and 16 threads (80-103 ms and 63-77 ms, before 149-184 ms and 79-86 ms).
    • Matmul on 1, 4, 8 and 16 threads: 3.1, 1.0, 0.53 and 0.43 s.
    • The cost is contention on buffers that every thread copies. 64 bodies that each push 500,000 references to the same 64 strings take 1.1 s. With frozen counts, LLVM removes the counting (19 ms).
  18. Lay out values as tightly as Rust. The cases of a union share storage. An AST node of four cases went from 88 to 40 bytes. Before, a tree walk missed the last-level cache 300 times as often as Rust (Calculator 2.0 s to 0.7 s). Dup and drop examine only the active case. A record of more than six words passes by pointer, as &T. A local or element passes where it is. A pattern binding of a place that is not written borrows its field.
  19. Copy shared buffers one time, outside the loop. A p.f[i] = x in a loop checks the uniqueness of p.f before the loop, when nothing in the loop can share p.f. An out-of-line call copies a shared buffer and returns the new one. A page whose blocks are all free starts again at its beginning. Thus a phase that frees everything uses memory again in address order (Calculator after a graph phase: 1.77 s to 0.67 s).
  20. Use eight bytes for each step where the format allows it. Decimal digits convert eight at a time (SWAR, as in fast_float). The end of a JSON string is found eight bytes at a time. readU64LE is one check and one load. A length is known to be non-negative, so contracts on it fold index checks. Sorting keeps existing runs (natural merge sort) and merges without branches. Base64 uses AVX2 when it is available (Muła-Lemire), and is faster than the Rust crate. A Gizlang version took 33/38 ms for 30 MB against 9/7 ms, so base64 went back to C (the rule: within 1.3 times). The base64 entry of bench/compiler/perf tracks the scalar gap (2.2 times C). Refer to RUNTIME.

Rules for new code

  • A library function on a hot path states its cost: what it allocates and what it copies.
  • Inside a function, prefer positions and borrowed views. Make owned values at its edge.
  • Before you add a cache, a fast path or a special case, measure what it saves on the benchmark that owns it. Put the measurement in the commit message.

Published results

A benchmark suite is bench/AREA/NAME/suite.giz, a program that ends in Bench.main (gizlang:bench). It names its items, subjects (Gizlang and what it is compared with), groups (one table each, with its method) and goals.

sh
bin/giz bench list                          # the suites and their published files
bin/giz bench run SUITE [--size quick]      # measure; print beside the published values
bin/giz bench compare SUITE/ITEM            # one row
bin/giz bench record SUITE [ITEM...]        # a full run, merged into the published file
  • record writes bench/results/AREA/SUITE.json, with the date, machine, build and size of each row. A row with source migrated came from a README before its suite existed; the site marks it.
  • gizstack.com shows the files only through :::bench{suite=...}, :::stats{items="SUITE/ITEM ..."}, :::goals{suites=...} and the value bench.SUITE.ITEM.SUBJECT in double braces in prose (stack/site/bench.giz; / in a suite id becomes -). tests/stack/site/bench.test.giz fails when a page or doc writes a published value of three or more digits by hand.
  • A release ships each suite that imports only gizlang: modules and its own folder, with the published files, under share/gizlang/bench and share/gizlang/results (tools/cli/bench.giz pack), so a user measures again with no product source. A suite that imports a product source stays in the checkout, and its tables say so.
  • A bench README holds the method and dated analyses. It does not repeat a published number.

Benchmark rules

These rules apply to every folder in bench/. The owner team of a folder applies them to its folder.

FolderOwner
bench/db, bench/db/kvdatabase
bench/fsfs
bench/web, bench/web/http, bench/web/httparenaweb
bench/stack/builderGizStack
bench/compiler/agents, bench/compiler/regressions, bench/compiler/langarena, bench/sdk/hot, bench/compiler/perf, bench/runtime/tasks, bench/compiler/livelanguage
  1. Keep what a result cites. Keep a benchmark when bench/AREA/README.md or a document cites its result. Also keep its comparison sources in other languages (C, Rust, TypeScript, Go, Java, Next.js, Svelte, Solid) and the command that reproduces the result. A published number is only in its results file (published results).
  2. Remove the rest. Remove a benchmark that no result cites, a benchmark that does the same work as another, a copy of library code, a description of a file that does not exist (for example run.py), and an old result. Before you remove a benchmark, make sure that the results file or the README keeps the results that it gave.
  3. One harness. Tests and benchmarks use one harness module for processes, ports, scratch folders and resource readings. Find descendant processes in one way (through /proc), not with ps or pgrep.
  4. No imports between tests/ and bench/. Neither imports the other. A fixture that both use belongs to one owner module: the package under test, or a fixture module that both import.
  5. Every benchmark builds in the tests. tests/bench/run.giz checks that each benchmark program builds, so that a benchmark cannot go stale without a failed test.
  6. One comparison per answer format. Gizlang code compares answers. A program in another language is not kept: a Gizlang client speaks the other system's protocol (for example tests/db/oracle/golden.giz, which collects answers from Oracle over TNS).