Skip to content

Benchmarks

There are two benchmark setups with different purposes.

Continuous benchmark — source and results athttps://github.com/ringsaturn/tz-benchmark, visualized athttps://ringsaturn.github.io/tz-benchmark/. It runs in GitHub Actions on each release for cross-package comparison. Runner hardware differs from a developer machine, so absolute numbers differ from local runs while the relative ordering between packages is comparable. The continuous run has covered tzf v2 since 2026-09-11.

Dated snapshots — the directories undersnapshot/ are captured locally on an Apple M3 Max. Everything on this page comes from the2026-09-14 snapshot, measured against the published tzf v2.1.1 and tzf-rs 2.1.1 releases and the tzfpy 2.1.0b2 pre-release (lite and +full wheels), all on tzf-dist v0.0.2026-c-tzb2.

Methodology

Each finder is initialized once and reused for all queries, matching the documented production pattern. Queries sample two datasets: 154,694 world cities (gt_cities.csv) and 23,408 border-adjacent points (gt_edges.csv), both resolved against full-precision 2026c ground truth. Accuracy also runs a uniform random dataset of 1,000,000 points in Go.

Memory is measured per candidate in an isolated child process. Four columns appear below, and they are not interchangeable:

ColumnMeaning
BaselineRSS before the candidate is constructed
Init peakHigh-water mark (ru_maxrss) reached while loading. A container memory limit has to accommodate this value, or the process is killed at startup even though its steady state would have fit
LiveData the candidate retains once ready to serve queries, from language-native accounting: Go HeapAlloc after a forced GC, Rust a counting global allocator. n/a for Python, whose data lives outside the Python heap
RSS after loadWhat the OS reports for a process ready to serve queries. Closer to Init peak than to Live, because freeing memory does not shrink RSS: the allocator keeps the pages mapped for reuse

The relation Live <= RSS after load <= Init peak holds for every candidate.

Query latency

Apple M3 Max, 2026c dataset, 2026-09-14 snapshot.

Go (tzf v2)

Benchmarkns/opp50 (ns)p99 (ns)B/opallocs/op
NewDefaultFinder, world cities345.8208.0150000
NewDefaultFinder, edge cities543.7459.0120800
NewEmbeddedFinder, world cities533.7333.0250000
NewEmbeddedFinder, edge cities11621000279100
NewFullFinder, world cities347.9208.0158300
NewFullFinder, edge cities606.7500.0175000

All three finders query without allocating.

Rust (tzf-rs 2.1)

Benchmarkns/iterstddev (ns)
DefaultFinder, random city221.1341.39
DefaultFinder, random edge city474.8352.12
EmbeddedFinder, random city292.6851.42
EmbeddedFinder, random edge city666.1148.17

Python (tzfpy 2.1.0b2)

pytest-benchmark, one get_tz() call per round.

BenchmarkMedian (ns)Mean (ns)OPS (Kops/s)
Random cities, lite625.0718.71,391.4
Random edge cities, lite834.0907.01,102.5
Random cities, +full667.0822.71,215.5
Random edge cities, +full1,167.01,292.3773.8

Per-call overhead is comparable to the Rust figures; the difference reflects the Python-to-Rust call cost through PyO3. The +full rows are the experimental full-precision wheel from tzfpy’s own index, which runs tzf-rs’sEmbeddedFinder over full.tzb.

Accuracy

Wrong-answer rates against full-precision 2026c ground truth. “Offset-eq” counts wrong answers that resolve to the same UTC offset.

DatasetNCandidateWrongWrong %Offset-eq
cities154,694Go NewDefaultFinder (lite .tzm)10.00061
cities154,694Go NewEmbeddedFinder (lite .tzb)10.00061
cities154,694Go NewFullFinder (full .tzb)00.00000
cities154,694Rust DefaultFinder10.00061
cities154,694Rust EmbeddedFinder10.00061
cities154,694tzfpy10.00061
cities154,694tzfpy +full00.00000
edges23,408Go NewDefaultFinder (lite .tzm)10.00431
edges23,408Go NewFullFinder (full .tzb)00.00000
edges23,408Rust DefaultFinder10.00431
edges23,408tzfpy10.00431
edges23,408tzfpy +full00.00000
uniform1,000,000Go NewDefaultFinder (lite .tzm)190.001914
uniform1,000,000Go NewFullFinder (full .tzb)00.00000

The lite and full finders differ only near borders. The simplification bound is 111.2 m; see FAQ for the full displacement table.

Memory

Values in MiB.

Go

CandidateBaselineInit peakLiveRSS after load
Go runtime floor4.74.70.25.0
NewDefaultFinder (lite .tzm)5.141.513.141.5
NewEmbeddedFinder (lite .tzb in place)5.29.20.39.6
NewFullFinder (full .tzb)5.2315.5147.0315.5

Rust

CandidateBaselineInit peakLiveRSS after load
Rust runtime floor5.85.90.05.9
DefaultFinder5.846.622.846.5
EmbeddedFinder5.810.30.210.4

EmbeddedFinder retains 0.2 MiB live: its data is the 'static embedded slice, and the counting allocator sees only the open-time index (chunk skip blocks and per-group latitude stripes) that tzf-rs 2.1 builds.

Python

CandidateBaselineInit peakLiveRSS after load
Python interpreter floor22.422.4n/a22.4
tzfpy (lite)22.460.3n/a60.2
tzfpy +full22.438.2n/a38.2

Observations

  • The in-place mechanism trades query latency for memory. In Go, init peak drops from 41.5 MiB to 9.2 MiB while the world-cities median rises from 208 ns to 333 ns and the edge-cities median from 459 ns to 1,000 ns.
  • Against the 2026-09-11 snapshot, the in-place edge-cities figures fell from 8,959 ns to 1,000 ns (Go p50) and from 4,779.81 ns to 666.11 ns (Rust mean). The change comes from the tzf 2.1 and tzf-rs 2.1 query-walk rewrite and the 64-point chunks in v0.0.2026-c-tzb2; the expanded finders are unchanged within noise, and results are identical.
  • The tzfpy +full pre-release answers edge cities at a 1,167 ns median against 834 ns for the lite wheel, at 38.2 MiB init peak against 60.3 MiB.
  • The full-precision dataset costs memory rather than latency. Go init peak rises from 41.5 MiB to 315.5 MiB, while the world-cities median stays at 208 ns.
  • Init peak overstates the steady-state cost. Go’s default finder peaks at 41.5 MiB while retaining 13.1 MiB; the full finder peaks at 315.5 MiB while retaining 147.0 MiB. Size a container for the peak and budget long-running cost from the retained figure.
  • Language runtime floors differ (Go 4.7 MiB, Rust 5.8 MiB, Python 22.4 MiB), so cross-language totals are only comparable after subtracting them.
  • The lite dataset disagrees with full-precision ground truth on 1 of 154,694 world cities, and that answer resolves to the same UTC offset.
Last updated on