One distribution named cultcache-py carried three libraries. Rust and TypeScript layer them as cultcache < cultnet < cultmesh; Python now does the same, one package per layer, module names unchanged so every `from cultmesh_py import ...` keeps working. Two imports ran against that layering and are resolved by moving code to where the dependency direction says it lives, not by adding a shim: - cultnet_py.interop_peer imported the Verse/peer catalog, authority lease, stream, and simulation-fact contracts from cultmesh_py. They are wire contracts a bare peer has to speak without owning a node, which is why cultnet-rs and cultnet-ts keep them in the net layer. They now live in cultnet_py.cultmesh_contracts; cultmesh_py re-exports them. - cultcache_py.benchmark imported cultnet_py. It measures framed CultNet parse beside cache paths, so it and compare_csharp move to cultnet_py. verify checks all three packages and moves to cultmesh_py. The single 6,693-line test module is split by what each test touches: a test using any cultmesh_py name lands in cultmesh-py, else cultnet_py in cultnet-py, else cultcache-py. Each suite runs with only its layer and the layers below on the path, so a test that reaches up fails to import rather than quietly binding the packages back together. 11 / 53 / 78 = the same 142. Workflows install all three; the publish job is one matrix over the three packages keyed by tag prefix. The TS test harnesses put all three src directories on PYTHONPATH. cultcache-py bumps to 0.2.0 because it no longer contains cultnet_py or cultmesh_py; the new packages start at 0.1.0. Nothing was ever published, so no PyPI consumer moves. cultcache-py 11 tests, path: cultcache-py/src cultnet-py 53 tests, path: + cultnet-py/src cultmesh-py 78 tests, path: + cultmesh-py/src python -m cultmesh_py.verify -> ok, all five checks cultnet_py.interop_peer imports with cultmesh_py absent Not run here: the cultnet-ts interop ring and cultcache-ts store matrix need dotnet/kotlin peers; their PYTHONPATH change is four lines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
145 lines
5.7 KiB
Python
145 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import threading
|
|
import time
|
|
import tracemalloc
|
|
from typing import Any
|
|
|
|
from cultcache_py import define_document_type
|
|
from cultmesh_py import CultMeshReactiveDocumentOptions, create_node
|
|
|
|
|
|
def percentile(ordered: list[float], ratio: float) -> float:
|
|
if not ordered:
|
|
return 0.0
|
|
index = max(0, min(len(ordered) - 1, int(len(ordered) * ratio + 0.999999) - 1))
|
|
return ordered[index]
|
|
|
|
|
|
def measure(document_count: int, idle_seconds: float, active_seconds: float) -> dict[str, Any]:
|
|
payload = "x" * (16 * 1024)
|
|
changed_document_count = max(1, document_count // 100)
|
|
document = define_document_type(
|
|
f"gamecult.mesh.python_performance_probe_{document_count}",
|
|
encode=lambda value: value,
|
|
decode=lambda value: value,
|
|
)
|
|
node = create_node(runtime_id=f"python-performance-{document_count}")
|
|
node.register_document(document)
|
|
reactive_documents = []
|
|
publish_events = [threading.Event() for _ in range(document_count)]
|
|
update_started_at = [0.0] * document_count
|
|
latencies: list[float] = []
|
|
payload_bytes_published = 0
|
|
publishes = 0
|
|
measurement_lock = threading.Lock()
|
|
|
|
for index in range(document_count):
|
|
key = f"performance:{index}"
|
|
node.put(document, key, {"id": key, "payload": payload, "revision": 1})
|
|
writer = node.authoritative_writer(document, key)
|
|
original_write = writer.write
|
|
|
|
def measured_write(value: Any, document_index: int = index, write=original_write) -> None:
|
|
nonlocal payload_bytes_published, publishes
|
|
encoded_bytes = len(document.encode_payload(value))
|
|
latency_ms = (time.perf_counter() - update_started_at[document_index]) * 1000
|
|
with measurement_lock:
|
|
payload_bytes_published += encoded_bytes
|
|
publishes += 1
|
|
latencies.append(latency_ms)
|
|
write(value)
|
|
publish_events[document_index].set()
|
|
|
|
writer.write = measured_write # type: ignore[method-assign]
|
|
reactive_documents.append(
|
|
writer.reactive(CultMeshReactiveDocumentOptions(flush_delay_seconds=0.0))
|
|
)
|
|
|
|
tracemalloc.start()
|
|
idle_cpu_start = time.process_time()
|
|
time.sleep(idle_seconds)
|
|
idle_current_bytes, idle_peak_bytes = tracemalloc.get_traced_memory()
|
|
idle_cpu_ms = (time.process_time() - idle_cpu_start) * 1000
|
|
tracemalloc.reset_peak()
|
|
active_current_start, _ = tracemalloc.get_traced_memory()
|
|
active_cpu_start = time.process_time()
|
|
active_started_at = time.perf_counter()
|
|
frames = 0
|
|
while time.perf_counter() - active_started_at < active_seconds:
|
|
for index in range(changed_document_count):
|
|
publish_events[index].clear()
|
|
update_started_at[index] = time.perf_counter()
|
|
reactive_documents[index].update(
|
|
lambda draft: draft.__setitem__("revision", draft["revision"] + 1)
|
|
)
|
|
for index in range(changed_document_count):
|
|
if not publish_events[index].wait(timeout=1.0):
|
|
raise RuntimeError(f"Python reactive publication timed out for document {index}")
|
|
frames += 1
|
|
remaining = frames / 60 - (time.perf_counter() - active_started_at)
|
|
if remaining > 0:
|
|
time.sleep(remaining)
|
|
|
|
active_elapsed_seconds = time.perf_counter() - active_started_at
|
|
active_cpu_ms = (time.process_time() - active_cpu_start) * 1000
|
|
active_current_bytes, active_peak_bytes = tracemalloc.get_traced_memory()
|
|
tracemalloc.stop()
|
|
for reactive in reactive_documents:
|
|
reactive.dispose()
|
|
ordered = sorted(latencies)
|
|
return {
|
|
"documentCount": document_count,
|
|
"changedDocumentCount": changed_document_count,
|
|
"frames": frames,
|
|
"expectedPublishes": frames * changed_document_count,
|
|
"actualPublishes": publishes,
|
|
"payloadBytesPublished": payload_bytes_published,
|
|
"idleTracedCurrentBytes": idle_current_bytes,
|
|
"idleTracedPeakBytes": idle_peak_bytes,
|
|
"idleCpuMilliseconds": idle_cpu_ms,
|
|
"activeTracedRetainedGrowthBytes": active_current_bytes - active_current_start,
|
|
"activeTracedPeakGrowthBytes": active_peak_bytes - active_current_start,
|
|
"activeCpuMilliseconds": active_cpu_ms,
|
|
"publishedPayloadBytesPerSecond": payload_bytes_published / active_elapsed_seconds,
|
|
"p50PublishLatencyMilliseconds": percentile(ordered, 0.50),
|
|
"p95PublishLatencyMilliseconds": percentile(ordered, 0.95),
|
|
"p99PublishLatencyMilliseconds": percentile(ordered, 0.99),
|
|
"schedulerThreads": sum(
|
|
thread.name == "cultmesh-reactive-scheduler" for thread in threading.enumerate()
|
|
),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--quick", action="store_true")
|
|
args = parser.parse_args()
|
|
idle_seconds = 0.25 if args.quick else 10.0
|
|
active_seconds = 0.5 if args.quick else 10.0
|
|
results = [measure(count, idle_seconds, active_seconds) for count in (1, 100, 1000)]
|
|
print(json.dumps({
|
|
"runtime": "python",
|
|
"workload": {
|
|
"documentCounts": [1, 100, 1000],
|
|
"payloadBytes": 16 * 1024,
|
|
"idleSeconds": idle_seconds,
|
|
"activeSeconds": active_seconds,
|
|
"updateRateHz": 60,
|
|
"changedFraction": 0.01,
|
|
},
|
|
"results": results,
|
|
}, indent=2))
|
|
return 0 if all(
|
|
result["actualPublishes"] == result["expectedPublishes"]
|
|
and result["p99PublishLatencyMilliseconds"] < 250
|
|
and result["activeTracedPeakGrowthBytes"] < 128 * 1024 * 1024
|
|
and result["schedulerThreads"] == 1
|
|
for result in results
|
|
) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|