Making Tile Output Deterministic

This page makes a tiling pipeline produce byte-identical output from identical inputs — pinning tool versions, sorting every input collection, removing embedded timestamps and hostnames, controlling floating-point rounding and thread non-determinism, and proving it with a double build that compares hashes, for a city tiled from EPSG:25832 into EPSG:4978.

Why you hit this

Determinism sounds like a purity concern and is the foundation of three practical things. Incremental builds rely on it: if re-tiling an unchanged shard produces different bytes, the content hash changes, the CDN cache is invalidated and “incremental” means rebuilding the city. Caching relies on it, because an immutable URL whose content varies is a lie. And debugging relies on it — a difference between two builds is either a real change or noise, and a pipeline with noise cannot tell you which. The incremental machinery that depends on this is in incremental retiling of changed city blocks.

Prerequisites

  • Python 3.10+ with numpy>=1.24; the tiling pipeline itself and whatever encoders it calls.
  • A container image for the build, or at least a lock file pinning every dependency.
  • A shard whose inputs you can hold fixed, for the double-build test.

Step-by-Step

1. Find the sources of non-determinism

python
import hashlib
import json
import os
import subprocess
from pathlib import Path

def file_digest(path, chunk=1 << 20):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        while block := f.read(chunk):
            h.update(block)
    return h.hexdigest()

def tree_digests(root):
    return {str(p.relative_to(root)): file_digest(p)
            for p in sorted(Path(root).rglob("*")) if p.is_file()}

def diff_digests(a, b):
    keys = sorted(set(a) | set(b))
    return [{"path": k, "a": a.get(k, "missing")[:12], "b": b.get(k, "missing")[:12]}
            for k in keys if a.get(k) != b.get(k)]

first = tree_digests("build/run_a/shards/120210233010")
second = tree_digests("build/run_b/shards/120210233010")
differences = diff_digests(first, second)
print(f"{len(differences)} of {len(first)} files differ between two builds of the same shard")
for d in differences[:6]:
    print(f"  {d['path']:<32}{d['a']}  {d['b']}")

Running the same shard twice and diffing the hashes is the whole diagnostic. Almost every pipeline fails it the first time, and the files that differ point straight at the cause: a tileset.json that differs means a timestamp or a key order, a .glb that differs means the encoder or the geometry order, and a difference in every file means a tool version or a path baked into the output.

Where non-determinism comes from A table of causes and the file each one changes. An embedded build timestamp changes the tileset JSON. Unsorted input order changes vertex order in the geometry file. Dictionary or set iteration changes JSON key order. Parallel reduction changes floating-point sums. A tool version change alters the encoded bytes. An absolute path recorded in metadata changes with the working directory. causewhat changesfix build timestamp in metadatatileset.jsonomit or pin it unsorted input iterationvertex order in .glbsort by a stable key dict or set iteration orderJSON key ordersort_keys=True parallel float reductionbounding volumesfixed-order sums tool version or pathevery encoded filepin the image
Five causes, and the file that differs tells you which one you have before you read any code.

2. Sort every input, by a key that cannot change

python
def stable_feature_order(features):
    """Deterministic order: by identifier, which is stable across runs and deliveries."""
    return sorted(features, key=lambda f: (str(f["feature_id"]),))

def stable_shard_order(shard_keys):
    return sorted(shard_keys)                         # quadkeys sort lexicographically

def stable_file_list(root, pattern="*.laz"):
    return sorted(Path(root).glob(pattern), key=lambda p: p.name)

Filesystem iteration order is not defined — Path.glob and os.listdir return entries in whatever order the filesystem provides, which differs between ext4 and XFS, between a local disk and object storage, and sometimes between two runs on the same directory. Sorting by name is one call and removes the whole class of problem.

Sorting features by identifier rather than by position matters for a subtler reason: a spatial sort is stable only if the coordinates are identical, and a delivery that shifts one building by a millimetre reorders everything after it. An identifier sort survives that, so a shard whose contents did not change produces the same vertex order even when a neighbour moved.

3. Remove the timestamps and the environment

python
def deterministic_tileset(root_tile, dataset_version, geometric_error):
    """No build time, no hostname, no absolute paths — only inputs."""
    return {
        "asset": {
            "version": "1.1",
            "tilesetVersion": dataset_version,          # the data's version, not the build's time
        },
        "geometricError": round(geometric_error, 6),
        "root": root_tile,
    }

def write_json_deterministic(path, obj):
    text = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True,
                      allow_nan=False)
    Path(path).write_text(text, encoding="utf-8", newline="\n")
    return file_digest(path)

def deterministic_zip(out_path, files):
    """A zip with fixed timestamps and order, for packaged outputs."""
    import zipfile
    with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as z:
        for arcname, src in sorted(files.items()):
            info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
            info.compress_type = zipfile.ZIP_DEFLATED
            info.external_attr = 0o644 << 16
            z.writestr(info, Path(src).read_bytes())
    return file_digest(out_path)

sort_keys=True fixes JSON key order, which in CPython is insertion order and therefore depends on the code path that built the dictionary. allow_nan=False turns a NaN — which would serialise as the non-standard NaN token — into an error, which is better than a tileset that some parsers reject.

The version string is the substantive change: replacing a build timestamp with the data’s version means two builds of the same data produce the same file, while a new delivery still changes it. Where a build time is genuinely needed, the standard approach is to honour SOURCE_DATE_EPOCH from the environment so a rebuild can reproduce an earlier one.

4. Control the arithmetic

python
import numpy as np

def deterministic_bounds(points):
    """Min/max are exact; means and sums need a fixed reduction order."""
    lo = points.min(axis=0)
    hi = points.max(axis=0)
    centre = (lo + hi) / 2.0                        # from extremes, not from a mean
    half = (hi - lo) / 2.0
    return np.round(centre, 6), np.round(half, 6)

def deterministic_mean(values):
    """Pairwise summation in a fixed order, independent of thread count."""
    v = np.sort(np.asarray(values, dtype=np.float64))   # sorting makes the order reproducible
    return float(v.sum() / len(v)) if len(v) else 0.0

def quantise(values, scale=1e-3):
    """Round to a fixed grid so tiny input differences cannot flip a rounding decision."""
    return np.round(np.asarray(values, dtype=np.float64) / scale).astype(np.int64) * scale

Floating-point addition is not associative, so a sum computed by four threads and a sum computed by one differ in the last bits, and a bounding volume derived from a mean differs between runs on machines with different core counts. Deriving the centre from the extremes avoids the reduction entirely. Where a mean is genuinely needed, sorting before summing makes the order reproducible at a small cost.

Quantising before writing is the other half. Two runs that compute a coordinate as 12.000000000000002 and 11.999999999999998 write different bytes unless the value is snapped to a grid first — and the grid is the tile’s quantisation step anyway, so nothing is lost.

python
def set_deterministic_env():
    """Environment variables that remove thread and hash non-determinism."""
    os.environ.setdefault("PYTHONHASHSEED", "0")
    for var in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS",
                "NUMEXPR_NUM_THREADS", "GDAL_NUM_THREADS"):
        os.environ.setdefault(var, "1")
    os.environ.setdefault("SOURCE_DATE_EPOCH", "1758067200")
    return {k: os.environ[k] for k in ("PYTHONHASHSEED", "OMP_NUM_THREADS", "SOURCE_DATE_EPOCH")}

print(set_deterministic_env())

PYTHONHASHSEED matters because set iteration order depends on it, and a pipeline that iterates a set of feature identifiers anywhere produces a different order on every run. Setting the thread counts to one is a blunt instrument that guarantees reproducible reductions; the alternative is to keep threads and make every reduction order-independent, which is more work and faster.

Environment variables that decide whether a build repeats A table of four environment settings and what each one fixes. PYTHONHASHSEED set to zero fixes set and dictionary iteration order. The OpenMP and BLAS thread counts set to one fix the order of floating-point reductions. SOURCE_DATE_EPOCH fixes any timestamp a format requires. GDAL_NUM_THREADS set to one fixes raster block ordering. Each is one line and each removes a whole class of non-reproducible output. settingvaluewhat it fixescost PYTHONHASHSEED0set and dict iteration ordernone OMP_NUM_THREADS1float reduction ordersingle-threaded maths SOURCE_DATE_EPOCHfixedtimestamps a format demandsnone GDAL_NUM_THREADS1raster block orderingslower raster I/O Four lines in the container's environment, and three of them cost nothing. The thread counts are the only trade: reproducible reductions against parallel speed.
The cheapest determinism available: four environment variables, set once in the build image.

5. Record what produced the output

python
def provenance(params, tool_versions, inputs):
    """Everything that affects the bytes, hashed into one value."""
    payload = {
        "params": params,
        "tools": tool_versions,
        "inputs": {name: file_digest(path) for name, path in sorted(inputs.items())},
    }
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return {"provenance": payload,
            "provenance_hash": hashlib.sha256(canonical.encode()).hexdigest()}

TOOLS = {
    "python": subprocess.run(["python", "-VV"], capture_output=True, text=True).stdout.strip(),
    "tiler": "twin-tiler 4.2.1",
    "draco": "1.5.6",
    "gltf-transform": "4.0.1",
    "image": "ghcr.io/example/twin-tiler@sha256:3f0a…",
}
prov = provenance({"max_per_tile": 2000, "quantize_position": 14},
                  TOOLS, {"footprints": "source/footprints.gpkg"})
print(prov["provenance_hash"][:16], "…")

The provenance hash is what makes determinism useful rather than merely satisfying. Two tiles with the same provenance hash and different content hashes mean the pipeline is non-deterministic; the same content hash with a different provenance hash means something irrelevant changed. Storing it per shard turns the incremental build’s “is this shard up to date?” into a comparison of two strings.

6. Prove it with a double build in CI

python
def double_build_check(build_fn, shard, work_root="build/determinism"):
    digests = []
    for run in ("a", "b"):
        out = Path(work_root) / run
        if out.exists():
            import shutil
            shutil.rmtree(out)
        out.mkdir(parents=True)
        build_fn(shard, out)                        # the real pipeline, twice
        digests.append(tree_digests(out))
    diffs = diff_digests(*digests)
    return {"files": len(digests[0]), "differing": len(diffs), "deterministic": not diffs,
            "first_differences": diffs[:5]}

result = double_build_check(build_shard_to, "120210233010")
print(json.dumps(result, indent=2))
assert result["deterministic"], "tiling is not deterministic; see first_differences"

Running the check on one representative shard in CI, on every merge, is enough: non-determinism is almost never shard-specific, and one shard takes seconds. Adding a second shard with textures is worthwhile, because image encoders are a common source — several JPEG and Basis encoders embed a library version or vary with thread count.

The double-build check in CI One shard is built twice from identical inputs in the same container image. The two output trees are hashed file by file and compared. Identical hashes pass the check. Any difference fails the build and names the first files that differ, which identifies the cause without further investigation. fixed inputs+ pinned image build run Aoutput tree build run Boutput tree hash every file,compare identical:pass differs: fail,name the files
A few seconds per merge, and the check that keeps every incremental build and every cache header honest.

Expected Output & Verification

text
3 of 5 files differ between two builds of the same shard
  tileset.json                    a41f0c2b9e14  7d2e88b41c03
  content/0/0/0.glb               9b02c4e7f1aa  1c8e4470a9d2
  content/0/0/1.glb               44a1e0b9c83f  2fe70c1ab884
{'PYTHONHASHSEED': '0', 'OMP_NUM_THREADS': '1', 'SOURCE_DATE_EPOCH': '1758067200'}
c41d9f8a2b703e55 …
{
  "files": 5,
  "differing": 0,
  "deterministic": true,
  "first_differences": []
}

The first block is the before state and the last is the after. Three of five files differed: tileset.json from a build timestamp, and the two .glb files from unsorted feature iteration — which is the typical pair, and both are fixed by the changes above.

Verify that determinism survives the things that legitimately vary, which is the property that makes it useful:

python
def determinism_matrix(build_fn, shard):
    """Same inputs must give the same bytes across worker counts and working directories."""
    import shutil
    cases = {
        "baseline": {"workers": 1, "cwd": "build/case_a"},
        "more_workers": {"workers": 8, "cwd": "build/case_b"},
        "other_cwd": {"workers": 1, "cwd": "build/case_c/nested/deeper"},
    }
    digests = {}
    for name, cfg in cases.items():
        out = Path(cfg["cwd"]) / "out"
        if out.exists():
            shutil.rmtree(out)
        out.mkdir(parents=True)
        build_fn(shard, out, workers=cfg["workers"])
        digests[name] = tree_digests(out)
    base = digests["baseline"]
    return {name: {"identical": d == base, "differing": len(diff_digests(base, d))}
            for name, d in digests.items()}

print(json.dumps(determinism_matrix(build_shard_to, "120210233010"), indent=2))

Worker count and working directory are the two variables that differ between a developer’s laptop and the build server, so a pipeline that is deterministic only at one worker count will still produce cache-invalidating output in production. Testing the matrix rather than a single repeat is what catches the parallel-reduction and absolute-path cases.

Then verify that a real change does change the bytes, which is the other half of the contract:

python
def change_detection_check(build_fn, shard, out_root="build/change"):
    base = Path(out_root) / "base"; base.mkdir(parents=True, exist_ok=True)
    build_fn(shard, base)
    moved = Path(out_root) / "moved"; moved.mkdir(parents=True, exist_ok=True)
    build_fn(shard, moved, nudge_one_building_m=0.02)      # 2 cm on one building
    diffs = diff_digests(tree_digests(base), tree_digests(moved))
    return {"differing_files": len(diffs), "detects_change": len(diffs) > 0}

print(change_detection_check(build_shard_to, "120210233010"))

A pipeline so aggressively quantised that a 2 cm move produces identical bytes is deterministic and useless, because the incremental build will never notice the change. The quantisation grid has to be finer than the smallest change the twin must reflect.

Performance Notes

  • Determinism costs a few percent, almost all of it in sorting inputs and in single-threaded reductions. On a city job that is minutes against hours, and it is repaid the first time an incremental build skips 4,000 unchanged shards.
  • Sorting is cheap; re-sorting is not. Sort once, at the point the collection is formed, and keep the order through the pipeline.
  • Single-threaded native libraries are the expensive part. Where a stage is reduction-heavy, prefer an order-independent algorithm over disabling threads.
  • Hash with sha256 and be done. Hashing a 1 MB tile takes microseconds, and a faster non-cryptographic hash saves nothing measurable while making collisions a conversation.
  • Run the double-build check on one shard, not the city. It is a property of the code, not of the data.

Common Errors

Only the tileset JSON differs. A timestamp, a hostname or a key order. The three fixes in step 3 cover all of them.

Only the compressed geometry differs. The encoder is non-deterministic, or its version changed. Pin the encoder in the image and check whether it has a deterministic flag; several accept a fixed random seed.

Output differs between the laptop and CI but not between two CI runs. An absolute path, a locale, or a different library build. The pinned image removes all three, which is why the double build must run in the same image the pipeline uses.

Everything is identical and the incremental build still rebuilds everything. The provenance hash includes something that changes per run — a build timestamp, a run identifier, an absolute path. It should contain only inputs, parameters and tool versions.

A textured tile is deterministic on one machine and not another. Image encoders vary with SIMD availability. Either pin the encoder’s instruction-set level or accept per-machine variation and always build on one class of runner.

Frequently Asked Questions

Is bit-for-bit determinism necessary, or is “semantically equal” enough?

For caching and incremental builds it must be bit-for-bit, because the comparison is a hash. For validation, semantic equality is enough — and a pipeline that can only manage the second cannot have immutable URLs.

What about SOURCE_DATE_EPOCH?

It is the reproducible-builds convention for injecting a fixed timestamp, and honouring it is the right way to keep a build time in the output without losing determinism. Any format with a mandatory timestamp field — zip archives, some image containers — should read it.

Does quantisation hide real changes?

It can, which is why the change-detection check exists. Choose the grid from the smallest change the twin must reflect — a centimetre for building geometry — and verify that a change at that scale alters the bytes.

Back to 3D Tiles Batch Tiling Pipelines.