Skip to content

Commit 69f4b04

Browse files
antiguruclaude
andcommitted
benchmarks: price oversubscription and publication at scale
The first nightly with the interactive runtime showed reads isolated from hydration and nothing else moving, and left three questions the benchmarks did not ask. What a saturated interactive runtime does to maintenance, since two runtimes are twice the worker threads on the same cores. What publishing every index costs when there are hundreds of them and nobody reads. And whether `PeekIsolationUnderExpensivePeeks` guards anything, since its two sides came out identical. `MaintenanceUnderPeekSaturation` loads an eight-worker replica, half the cores of the nightly agents, with one join peek in flight per worker and two hydration churn loops, while a writer advances a small materialized view. The measured query reads that view strict serializable from a separate one-worker cluster, so it cannot answer before the view's write frontier passes the write and nothing else competes with it: its latency is the view's maintenance lag under the peek load. Read on the loaded replica it would instead queue behind the joins on the runtime that serves peeks, which is what the join loops' own latency reports. `ManyIndexesIdle` and `ManyIndexesRestart` publish two hundred indexed views over one table, each a distinct arrangement. The first measures one lookup and, through the clusterd memory column, the resident cost of two hundred publications. The second restarts the replica and waits for a read that imports every index, so every publication is rendered and bound again on the way up. `ManyReexportsIdle` is the other shape: two hundred indexes on one relation and key share one arrangement and re-export it, each under its own publication. `PeekIsolationUnderExpensivePeeks` walks five times as many rows half again as often, about 60% of the worker where the old sizing measured under 10%, at which the lookups did not notice the walks. Its docstring now says what it guards: both queries are peeks, so it measures how the serving runtime interleaves them, not the split between peeks and maintenance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDm7opomJLxbNUEP3r9BLk
1 parent 7ae0458 commit 69f4b04

2 files changed

Lines changed: 253 additions & 11 deletions

File tree

‎misc/python/materialize/feature_benchmark/scenarios/interactive_runtime.py‎

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@
1717
"""
1818

1919
from materialize.feature_benchmark.action import Action, TdAction
20-
from materialize.feature_benchmark.measurement_source import MeasurementSource, Td
21-
from materialize.feature_benchmark.scenario import Scenario
20+
from materialize.feature_benchmark.measurement_source import (
21+
Lambda,
22+
MeasurementSource,
23+
Td,
24+
)
25+
from materialize.feature_benchmark.scenario import BenchmarkingSequence, Scenario
2226

2327

2428
class InteractiveRuntime(Scenario):
@@ -103,3 +107,110 @@ def benchmark(self) -> MeasurementSource:
103107
/* B */
104108
1
105109
""")
110+
111+
112+
class ManyIndexes(InteractiveRuntime):
113+
"""Group parent: `INDEXES` indexed views over one table, plus a view over all of them for a
114+
read that answers only once every index has hydrated.
115+
116+
Each view is a distinct relation, so each index is its own arrangement with its own
117+
publication. An index that repeats an existing index's relation and key re-exports that
118+
arrangement instead, which `ManyReexportsIdle` prices separately."""
119+
120+
SCALE = 5
121+
INDEXES = 200
122+
123+
def init(self) -> list[Action]:
124+
views = "\n".join(
125+
f"> CREATE VIEW v_{i} AS SELECT f1 FROM t WHERE f1 % {self.INDEXES} = {i}\n"
126+
f"> CREATE DEFAULT INDEX ON v_{i}\n"
127+
for i in range(self.INDEXES)
128+
)
129+
union = " UNION ALL ".join(f"SELECT f1 FROM v_{i}" for i in range(self.INDEXES))
130+
return [
131+
self.table_ten(),
132+
TdAction(f"""
133+
> CREATE TABLE t (f1 INTEGER)
134+
135+
> INSERT INTO t SELECT {self.unique_values()} FROM {self.join()}
136+
137+
{views}
138+
139+
> CREATE VIEW all_v AS SELECT count(*) AS c FROM ({union})
140+
141+
> SELECT c FROM all_v
142+
{self.n()}
143+
"""),
144+
]
145+
146+
147+
class ManyIndexesIdle(ManyIndexes):
148+
"""One lookup with many published indexes in place. The wallclock is the lookup; the
149+
clusterd memory measurement is the resident cost of publishing `INDEXES` arrangements.
150+
"""
151+
152+
def benchmark(self) -> MeasurementSource:
153+
return Td(f"""
154+
> SELECT 1
155+
/* A */
156+
1
157+
158+
> SELECT count(*) FROM v_0
159+
/* B */
160+
{self.n() // self.INDEXES}
161+
""")
162+
163+
164+
class ManyIndexesRestart(ManyIndexes):
165+
"""Restart with many indexes, timed until a read importing all of them answers. Every index
166+
is rendered and published again on the way up, and the read binds every publication.
167+
"""
168+
169+
def benchmark(self) -> BenchmarkingSequence:
170+
return [
171+
Lambda(lambda e: e.RestartMzClusterd()),
172+
Td(f"""
173+
> SELECT c FROM all_v
174+
/* B */
175+
{self.n()}
176+
"""),
177+
]
178+
179+
180+
class ManyReexportsIdle(InteractiveRuntime):
181+
"""One lookup with many re-exported indexes in place: `INDEXES` indexes on one relation and
182+
key share one arrangement, and each publishes it under its own id. The clusterd memory
183+
measurement is the resident cost of `INDEXES` publications of the same arrangement.
184+
"""
185+
186+
SCALE = 5
187+
INDEXES = 200
188+
189+
def init(self) -> list[Action]:
190+
indexes = "\n".join(
191+
f"> CREATE INDEX t_f1_{i} ON t (f1)\n" for i in range(self.INDEXES)
192+
)
193+
return [
194+
self.table_ten(),
195+
TdAction(f"""
196+
> CREATE TABLE t (f1 INTEGER)
197+
198+
> INSERT INTO t SELECT {self.unique_values()} FROM {self.join()}
199+
200+
{indexes}
201+
202+
> SELECT count(*) FROM t
203+
{self.n()}
204+
"""),
205+
]
206+
207+
def benchmark(self) -> MeasurementSource:
208+
return Td("""
209+
> SELECT 1
210+
/* A */
211+
1
212+
213+
> SELECT f1 FROM t WHERE f1 = 1
214+
/* B */
215+
1
216+
""")

‎misc/python/materialize/parallel_benchmark/scenarios.py‎

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import queue
1111
import time
1212
from copy import deepcopy
13+
from dataclasses import replace
1314

1415
import psycopg
1516

@@ -1528,16 +1529,21 @@ class PeekIsolationUnderExpensivePeeks(Scenario):
15281529
size and the peek response stash out of the measurement. The cheap query is
15291530
a literal lookup on the key, and its p99 is what this reports.
15301531
1532+
Both queries are peeks, so they share a runtime wherever peeks are placed.
1533+
This guards how the runtime that serves peeks interleaves a cheap one with
1534+
an expensive one, not the split between peeks and maintenance.
1535+
15311536
Three things the numbers depend on, none of which the output would reveal if
15321537
they stopped holding:
15331538
15341539
* The expensive loop has to leave the worker idle between walks, or the
15351540
lookups queue without bound and the percentiles report the length of the
15361541
load phase. The measured cluster is one worker, since `Materialized` boots
15371542
at the `bootstrap` replica size and `--size` does not reach it, so the rate
1538-
is set against one walk: at 100ns to 1us per position, 100,000 positions
1539-
is 10ms to 100ms, and 2/s of those is 2% to 20% of that worker. Raising
1540-
the rate or the row count means redoing this.
1543+
is set against one walk: 100,000 positions measured about 40ms, so 500,000
1544+
is about 200ms, and 3/s of those is about 60% of that worker. At 100,000
1545+
positions and 2/s the lookups did not notice the walks at all. Raising the
1546+
rate or the row count means redoing this.
15411547
* The loops are pooled and the pool is far larger than the ~3 connections
15421548
they hold. Waiting for a connection is timed like waiting for the replica,
15431549
and `ReuseConnQuery` would cap concurrency at one and report a client-side
@@ -1571,7 +1577,7 @@ def __init__(self, c: Composition, conn_infos: dict[str, PgConnInfo]):
15711577
# queued behind it and short enough to leave the worker idle
15721578
# between walks. The class docstring has the arithmetic.
15731579
> CREATE TABLE hot (k int, v int)
1574-
> INSERT INTO hot SELECT n, n * 2 FROM generate_series(1, 100000) AS n
1580+
> INSERT INTO hot SELECT n, n * 2 FROM generate_series(1, 500000) AS n
15751581
> CREATE INDEX hot_k ON hot (k)
15761582
15771583
# Wait for the index to hydrate before measuring.
@@ -1593,7 +1599,7 @@ def __init__(self, c: Composition, conn_infos: dict[str, PgConnInfo]):
15931599
# nothing and every position is examined for no rows.
15941600
OpenLoop(
15951601
action=PooledQuery("SELECT v FROM hot WHERE v = -1"),
1596-
dist=Periodic(per_second=2),
1602+
dist=Periodic(per_second=3),
15971603
report_regressions=False,
15981604
),
15991605
],
@@ -1740,10 +1746,9 @@ class FreshnessUnderPeekWalks(Scenario):
17401746
connection; the contention is a fixed rate of full index walks on the same
17411747
replica.
17421748
1743-
The walk table is sized as in `PeekIsolationUnderExpensivePeeks`: each walk
1744-
is long enough to hold the frontier back measurably and short enough to
1745-
leave the worker idle between walks, so the read does not queue without
1746-
bound.
1749+
The walk table is 100,000 rows at 2/s, about 40ms per walk on one worker,
1750+
so each walk holds the frontier back measurably while leaving the worker
1751+
idle between walks and the read does not queue without bound.
17471752
"""
17481753

17491754
def __init__(self, c: Composition, conn_infos: dict[str, PgConnInfo]):
@@ -1810,3 +1815,129 @@ def __init__(self, c: Composition, conn_infos: dict[str, PgConnInfo]):
18101815
"SELECT count(*) FROM fresh_w WHERE k = 0 (reuse connection)": CONTENDED_THRESHOLDS,
18111816
},
18121817
)
1818+
1819+
1820+
class MaintenanceUnderPeekSaturation(Scenario):
1821+
"""Measures how far a saturating peek load holds back maintenance on the same
1822+
replica.
1823+
1824+
The other isolation scenarios saturate maintenance and measure peeks. This
1825+
is the converse. Cluster `sat` has eight workers, half the cores of the
1826+
agents the nightly runs on, and carries three loads: eight closed loops of a
1827+
join peek, one in flight per worker, so every worker that serves peeks is
1828+
busy; two hydration churn loops, so the maintenance workers are busy too;
1829+
and a small materialized view over a table a writer keeps advancing. With a
1830+
second runtime that is sixteen worker threads on sixteen cores, which is the
1831+
oversubscription this prices. With one runtime the eight workers do all of
1832+
it in turn.
1833+
1834+
The measured query is a strict serializable read of the materialized view
1835+
from `sat_idle`, a separate one-worker cluster with nothing else to do. It
1836+
cannot answer until the view's write frontier passes the write, so its
1837+
latency is the view's maintenance lag on `sat` plus one idle lookup. Read on
1838+
`sat` itself it would instead measure the lookup queueing behind the joins
1839+
on the runtime that serves peeks, which the join loops' own latency already
1840+
reports. That latency is reported too, as the serving runtime's throughput.
1841+
"""
1842+
1843+
def __init__(self, c: Composition, conn_infos: dict[str, PgConnInfo]):
1844+
# `connect()` issues `SET cluster` itself, which has to run in autocommit:
1845+
# `ReuseConnQuery` and `HydrationChurn` turn autocommit on afterwards and
1846+
# cannot while that statement's transaction is open. The clusters do not
1847+
# exist yet when these connections open, which is a notice, not an error.
1848+
sat = replace(conn_infos["materialized"], cluster="sat", autocommit=True)
1849+
idle = replace(conn_infos["materialized"], cluster="sat_idle", autocommit=True)
1850+
join = "SELECT count(*) FROM sat_big a JOIN sat_big b USING (k)"
1851+
churn_view_sql = "SELECT a, count(*) AS c FROM sat_churn GROUP BY a"
1852+
self.init(
1853+
[
1854+
TdPhase(f"""
1855+
> DROP TABLE IF EXISTS sat_w CASCADE
1856+
> DROP TABLE IF EXISTS sat_big CASCADE
1857+
> DROP TABLE IF EXISTS sat_churn CASCADE
1858+
> DROP CLUSTER IF EXISTS sat CASCADE
1859+
> DROP CLUSTER IF EXISTS sat_idle CASCADE
1860+
1861+
> CREATE CLUSTER sat SIZE 'scale=1,workers=8', REPLICATION FACTOR 1
1862+
> CREATE CLUSTER sat_idle SIZE 'scale=1,workers=1', REPLICATION FACTOR 1
1863+
1864+
# The written table and the view whose freshness is measured.
1865+
> CREATE TABLE sat_w (k int, v int)
1866+
> INSERT INTO sat_w SELECT n, n FROM generate_series(1, 1000) AS n
1867+
> CREATE MATERIALIZED VIEW sat_mv IN CLUSTER sat AS SELECT count(*) AS c FROM sat_w
1868+
1869+
# The join input.
1870+
> CREATE TABLE sat_big (k int, v int)
1871+
> INSERT INTO sat_big SELECT n, n * 2 FROM generate_series(1, 200000) AS n
1872+
> CREATE INDEX sat_big_k IN CLUSTER sat ON sat_big (k)
1873+
1874+
# The churn input.
1875+
> CREATE TABLE sat_churn (a int, b int)
1876+
> INSERT INTO sat_churn SELECT n, n % 1000 FROM generate_series(1, 1000000) AS n
1877+
1878+
> SET cluster = sat_idle
1879+
> SELECT c FROM sat_mv
1880+
1000
1881+
1882+
> SET cluster = sat
1883+
> {join}
1884+
200000
1885+
"""),
1886+
LoadPhase(
1887+
duration=120,
1888+
actions=[
1889+
# Contention: writes the read has to wait for.
1890+
ClosedLoop(
1891+
action=ReuseConnQuery(
1892+
"INSERT INTO sat_w VALUES (0, 0)",
1893+
sat,
1894+
strict_serializable=False,
1895+
),
1896+
report_regressions=False,
1897+
),
1898+
# Measured: a read that waits for the view's write
1899+
# frontier to pass the latest write, served off `sat`.
1900+
ClosedLoop(
1901+
action=ReuseConnQuery(
1902+
"SELECT c FROM sat_mv",
1903+
idle,
1904+
strict_serializable=True,
1905+
),
1906+
),
1907+
]
1908+
+ [
1909+
# Contention and measured: one peek dataflow in flight
1910+
# per worker.
1911+
ClosedLoop(action=PooledQuery(join))
1912+
for _ in range(8)
1913+
]
1914+
+ [
1915+
# Contention: continuous hydration on the maintenance
1916+
# workers.
1917+
ClosedLoop(
1918+
action=HydrationChurn(
1919+
sat, f"sat_churn_{i}", churn_view_sql
1920+
),
1921+
report_regressions=False,
1922+
)
1923+
for i in range(2)
1924+
],
1925+
),
1926+
TdPhase("""
1927+
> DROP TABLE IF EXISTS sat_w CASCADE
1928+
> DROP TABLE IF EXISTS sat_big CASCADE
1929+
> DROP TABLE IF EXISTS sat_churn CASCADE
1930+
> DROP CLUSTER IF EXISTS sat CASCADE
1931+
> DROP CLUSTER IF EXISTS sat_idle CASCADE
1932+
"""),
1933+
],
1934+
conn_pool_size=100,
1935+
conn_pool_setup=[
1936+
"SET TRANSACTION_ISOLATION TO 'SERIALIZABLE'",
1937+
"SET cluster = sat",
1938+
],
1939+
regression_thresholds={
1940+
"SELECT c FROM sat_mv (reuse connection)": CONTENDED_THRESHOLDS,
1941+
f"{join} (pooled)": CONTENDED_THRESHOLDS,
1942+
},
1943+
)

0 commit comments

Comments
 (0)