Files
bench/vs-ava/tp_loadgen_open.py
zeekayandHanzo Dev a02c92ac8f chain routes: /v1/bc -> /v1/chain
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.

Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-01 08:13:21 -07:00

192 lines
10 KiB
Python

#!/usr/bin/env python3
"""Live C-Chain throughput — OPEN-LOOP (saturating) variant of tp_loadgen.py.
The closed-loop sender advances one nonce per account only after the prior tx is
accepted, so the offered rate is coupled to the mined rate and cannot saturate the
block builder. This variant keeps a deep PIPELINE of consecutive-nonce txs in flight
per account (fire-and-forget, refreshed against the mined nonce), flooding the mempool
so the ONLY throttle left is the C-Chain block builder + fee/gas-target. That pins the
true saturated mined ceiling and the max block fullness. Same monitor/MEASURE contract."""
import os, json, time, random, threading, http.client
from eth_account import Account
_port = os.environ.get("PORT", "9640"); _ns = os.environ.get("NS", "lux-testnet")
_n = int(os.environ.get("NNODES", "5"))
_default = ",".join(f"luxd-{i}.luxd-headless.{_ns}.svc.cluster.local:{_port}" for i in range(_n))
NODES = [hp.rsplit(":", 1) for hp in os.environ.get("NODES", _default).split(",")]
HOST = [h for h, _ in NODES]; PORT = [int(p) for _, p in NODES]; NN = len(NODES)
RPC_PATH = "/v1/chain/C/rpc"
CHAINID = int(os.environ.get("CHAINID", "96368"))
N_KEYS = int(os.environ.get("N_KEYS", "60"))
FUND_LUX = int(os.environ.get("FUND_LUX", "5"))
PIPELINE = int(os.environ.get("PIPELINE", "64")) # in-flight nonces per account
WINDOW = int(os.environ.get("WINDOW", "90"))
SAMPLE = float(os.environ.get("SAMPLE", "3.0"))
SEND_TIMEOUT = float(os.environ.get("SEND_TIMEOUT", "3.0"))
def log(*a): print(f"[{time.strftime('%H:%M:%S')}]", *a, flush=True)
FUNDER_PK = os.environ["FUNDER_PK"].strip()
if not FUNDER_PK.startswith("0x"): FUNDER_PK = "0x" + FUNDER_PK
FUNDER = Account.from_key(FUNDER_PK); FUNDER_KEY = FUNDER.key; FUNDER_ADDR = FUNDER.address
del FUNDER_PK
log("funder", FUNDER_ADDR, "| OPEN-LOOP pipeline", PIPELINE, "| chainId", CHAINID)
random.seed(0xBEEF)
KEYS = [Account.create().key for _ in range(N_KEYS)]
ADDR = [Account.from_key(k).address for k in KEYS]
STOP = threading.Event(); SENT = [0]; SENT_LOCK = threading.Lock()
MINED = [0] * N_KEYS
def raw_of(s): return getattr(s, "raw_transaction", None) or getattr(s, "rawTransaction")
def sign_raw(key, nonce, gp, to, value=0, gas=21000):
return "0x" + raw_of(Account.sign_transaction({"nonce": nonce, "gasPrice": gp, "gas": gas, "to": to, "value": value, "data": b"", "chainId": CHAINID}, key)).hex()
def rpc_on(conn, method, params, rid=1):
conn.request("POST", RPC_PATH, json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}), {"Content-Type": "application/json"})
return json.loads(conn.getresponse().read())
def rpc(i, method, params, timeout=8):
c = http.client.HTTPConnection(HOST[i], PORT[i], timeout=timeout)
try: return rpc_on(c, method, params)
finally:
try: c.close()
except Exception: pass
def h2i(x):
try: return int(x, 16)
except Exception: return None
def get_gas_price():
try: bf = h2i(rpc(0, "eth_getBlockByNumber", ["latest", False])["result"].get("baseFeePerGas", "0x0")) or 0
except Exception: bf = 0
return max(bf * 8, 100_000_000_000)
def fund_all():
gp = get_gas_price()
base = h2i(rpc(0, "eth_getTransactionCount", [FUNDER_ADDR, "pending"], timeout=6).get("result"))
log(f"funding {N_KEYS} keys x {FUND_LUX} LUX nonce={base} gp={gp/1e9:.0f}gwei")
val = FUND_LUX * 10**18; W = 60
conn = http.client.HTTPConnection(HOST[0], PORT[0], timeout=8)
ni = 0; t0 = time.time(); last_prog = t0; last_mined = base
while True:
try: mined = h2i(rpc(0, "eth_getTransactionCount", [FUNDER_ADDR, "latest"])["result"])
except Exception: mined = last_mined
if mined is None: mined = last_mined
if mined > last_mined: last_prog = time.time(); last_mined = mined
if ni >= N_KEYS and mined >= base + N_KEYS: break
while ni < N_KEYS and (base + ni - mined) < W:
try: r = rpc_on(conn, "eth_sendRawTransaction", [sign_raw(FUNDER_KEY, base + ni, gp, ADDR[ni], val)])
except Exception:
try: conn.close()
except Exception: pass
conn = http.client.HTTPConnection(HOST[0], PORT[0], timeout=8); break
s = str(r).lower()
if "result" in r or "already known" in s or "nonce too low" in s: ni += 1
elif "underpriced" in s: gp = int(gp * 1.6); break
elif "full" in s or "limit" in s: break
else: ni += 1
if time.time() - last_prog > 25: gp = int(gp * 1.6); ni = max(0, mined - base); last_prog = time.time()
if time.time() - t0 > 240: log("funding timeout"); break
time.sleep(0.4)
funded = [i for i in range(N_KEYS) if (h2i(rpc(0, "eth_getBalance", [ADDR[i], "latest"]).get("result")) or 0) > 0]
log(f"funded {len(funded)}/{N_KEYS} in {time.time()-t0:.0f}s")
return funded
def refresher(funded):
"""Every 2s, refresh each account's mined nonce so pipeliners know how far to lead."""
while not STOP.is_set():
for i in funded:
try: MINED[i] = h2i(rpc(0, "eth_getTransactionCount", [ADDR[i], "latest"], timeout=6).get("result")) or MINED[i]
except Exception: pass
if STOP.is_set(): return
time.sleep(2)
def pipeliner(i, gp):
"""Fire-and-forget consecutive nonces, staying up to PIPELINE ahead of mined."""
conns = {}
def conn(j):
c = conns.get(j)
if c is None: c = http.client.HTTPConnection(HOST[j], PORT[j], timeout=SEND_TIMEOUT); conns[j] = c
return c
to = ADDR[i]; key = KEYS[i]; nxt = MINED[i]
while not STOP.is_set():
if nxt - MINED[i] >= PIPELINE:
time.sleep(0.02); nxt = max(nxt, MINED[i]); continue
j = random.randint(0, NN - 1)
try: raw = sign_raw(key, nxt, gp, to)
except Exception: break
try:
resp = rpc_on(conn(j), "eth_sendRawTransaction", [raw], rid=i)
if "result" in resp:
with SENT_LOCK: SENT[0] += 1
nxt += 1
else:
m = str(resp.get("error", {}).get("message", "")).lower()
if "nonce too low" in m: nxt = max(nxt + 1, MINED[i])
elif "already known" in m or "replacement" in m or "known transaction" in m: nxt += 1
elif "underpriced" in m or "full" in m or "limit" in m: time.sleep(0.02)
else: nxt += 1
except (http.client.HTTPException, OSError):
try: conns.pop(j).close()
except Exception: pass
time.sleep(0.02)
def block_hdr(i, num):
try:
b = rpc(i, "eth_getBlockByNumber", [hex(num), False]).get("result")
if not b: return None
return (len(b["transactions"]), h2i(b["gasUsed"]), h2i(b["gasLimit"]), h2i(b.get("baseFeePerGas", "0x0")) or 0, b["hash"])
except Exception: return None
def monitor(nsenders):
def tip(i=0):
try: return h2i(rpc(i, "eth_blockNumber", [])["result"])
except Exception: return None
while tip() is None and not STOP.is_set(): time.sleep(1)
t0 = time.time(); b0 = tip(); last = b0; sent0 = SENT[0]
log(f"=== OPEN-LOOP MEASURE START t0={t0:.1f} b0={b0} window={WINDOW}s pipeline={PIPELINE} ===")
tx_sum = 0; gas_sum = 0; gl = None; bfs = []; nb = 0; ints = []; last_bt = t0
peak_txblk = 0; peak_full = 0.0
end = t0 + WINDOW
while time.time() < end and not STOP.is_set():
time.sleep(SAMPLE); cur = tip()
if cur is None: continue
while last < cur:
last += 1; hdr = block_hdr(0, last)
if hdr is None: continue
txc, gu, glim, bf, _ = hdr
tx_sum += txc; gas_sum += gu; gl = glim; bfs.append(bf); nb += 1
peak_txblk = max(peak_txblk, txc); peak_full = max(peak_full, 100 * gu / (glim or 1))
now = time.time(); ints.append(now - last_bt); last_bt = now
off = SENT[0] - sent0; el = time.time() - t0
log(f"PROGRESS t={el:.0f}s tip={cur} blocks={nb} mined_tx={tx_sum} offered={off} "
f"off_tps={off/el:.1f} mined_tps={tx_sum/el:.1f} peak_tx/blk={peak_txblk} "
f"fullness={100*gas_sum/((gl or 1)*max(nb,1)):.2f}% peak_full={peak_full:.2f}%")
t1 = time.time(); el = t1 - t0; offered = SENT[0] - sent0
conf = max(b0 + 1, last - 2); hashes = {i: (block_hdr(i, conf)[4][:12] if block_hdr(i, conf) else "x") for i in range(NN)}
coherent = len(set(v for v in hashes.values() if v != "x")) <= 1
m = {"mode": "open-loop", "window_s": round(el, 2), "b0": b0, "b1": last, "blocks": nb,
"blocks_per_s": round(nb / el, 4) if el else 0, "pipeline": PIPELINE,
"offered_tx": offered, "offered_tps": round(offered / el, 2) if el else 0,
"mined_tx": tx_sum, "mined_tps": round(tx_sum / el, 2) if el else 0,
"avg_tx_per_block": round(tx_sum / nb, 2) if nb else 0, "peak_tx_per_block": peak_txblk,
"gas_used_sum": gas_sum, "gas_per_s": round(gas_sum / el, 1) if el else 0, "gas_limit": gl,
"avg_gas_per_block": round(gas_sum / nb, 1) if nb else 0,
"avg_fullness_pct": round(100 * gas_sum / ((gl or 1) * nb), 3) if nb else 0,
"peak_fullness_pct": round(peak_full, 3),
"base_fee_gwei_last": round((bfs[-1] / 1e9), 3) if bfs else 0,
"block_interval_ms_mean": round(1000 * sum(ints) / len(ints), 1) if ints else 0,
"coherent": coherent, "conf_height": conf, "node_hashes": hashes, "senders": nsenders, "nodes": NN}
log("MEASURE " + json.dumps(m)); STOP.set()
def main():
funded = [k for k in fund_all() if k < N_KEYS]
if len(funded) < 8: log(f"FATAL only {len(funded)} funded"); return
gp = get_gas_price()
log(f"=== OPEN-LOOP SATURATE: {len(funded)} accts x pipeline {PIPELINE}, measuring {WINDOW}s (gp={gp/1e9:.0f}gwei) ===")
for i in funded: MINED[i] = 0
threading.Thread(target=refresher, args=(funded,), daemon=True).start()
for i in funded: threading.Thread(target=pipeliner, args=(i, gp), daemon=True).start()
mon = threading.Thread(target=monitor, args=(len(funded),), daemon=True); mon.start()
mon.join(timeout=WINDOW + 60); STOP.set(); log("=== loadgen done ===")
if __name__ == "__main__":
try: main()
finally: STOP.set()