Skip to content

Commit 76dc72e

Browse files
committed
feat: add opt-in multithreaded fill to synthetic data generators
The synthetic generators are dominated by single-threaded numpy standard_normal draws (~0.9 GB/s), leaving cores idle. Add an opt-in n_workers parameter to scan_synthetic_regression and scan_synthetic_panel that fills the x/eps Gaussian draws in parallel. Draws are generated in fixed-size blocks (n_workers * fetch_size rows), each split across worker threads that fill disjoint slices in place via out=. standard_normal releases the GIL, so the fill scales across cores (~3x for regression, universe-dependent for panels). Reproducibility is re-keyed to (seed, n_workers, fetch_size) and stays independent of the batch_size Polars picks, so is_pure remains sound. n_workers=1 (default) keeps the exact serial path and its byte-for-byte output. Per-worker seed sub-streams are spawned lazily per block to keep seed storage at O(n_workers) and preserve bounded, streaming memory. Signed-off-by: Pascal Tomecek <40371786+ptomecek@users.noreply.github.com>
1 parent 363a4f8 commit 76dc72e

2 files changed

Lines changed: 203 additions & 46 deletions

File tree

polars_io_tools/io_sources/lazy_data_generator.py

Lines changed: 101 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@
2424
MeanComputer = Callable[[np.random.Generator, np.random.Generator, np.ndarray, int, int], tuple[np.ndarray, dict]]
2525

2626

27+
def _parallel_standard_normal(rngs: list[np.random.Generator], shape: tuple[int, int], pool) -> np.ndarray:
28+
"""Fill a ``shape`` array with standard-normal draws, one contiguous row-slice per generator.
29+
30+
Rows are split across ``rngs`` and each slice is filled in place via ``out=`` on ``pool``;
31+
``standard_normal`` releases the GIL, so the fill runs across cores. Following NumPy's
32+
documented multithreaded-generation pattern; slices are disjoint, so the writes never race.
33+
"""
34+
out = np.empty(shape, dtype=np.float64)
35+
chunks = np.array_split(out, len(rngs), axis=0)
36+
list(pool.map(lambda rng, chunk: rng.standard_normal(out=chunk), rngs, chunks))
37+
return out
38+
39+
2740
def _check_reserved(name: str, kind: str, *, n_features: int, n_responses: int, use_weights: bool, extra: tuple[str, ...] = ()) -> None:
2841
"""Raise ValueError if ``name`` collides with a generated column name."""
2942
reserved = {f"x{i}" for i in range(n_features)} | {f"y{i}" for i in range(n_responses)} | ({"weight"} if use_weights else set()) | set(extra)
@@ -46,11 +59,14 @@ def _register_source(
4659
mean_computer: MeanComputer,
4760
extras_schema: dict,
4861
chunk_sizes: np.ndarray | None = None,
62+
n_workers: int = 1,
4963
explain_name: str,
5064
explain_detail: str | None = None,
5165
) -> pl.LazyFrame:
5266
feature_cols = [f"x{i}" for i in range(n_features)]
5367
response_cols = [f"y{i}" for i in range(n_responses)]
68+
if n_workers < 1:
69+
raise ValueError(f"n_workers must be >= 1, got {n_workers}")
5470
schema_fields: dict[str, pl.DataType] = {name: pl.Float64() for name in feature_cols + response_cols}
5571
schema_fields.update(extras_schema)
5672
if use_weights:
@@ -65,62 +81,95 @@ def source_generator(
6581
) -> Iterator[pl.DataFrame]:
6682
bs = batch_size or fetch_size
6783
x_ss, eps_ss, w_ss, aux_ss, group_ss = np.random.SeedSequence(seed).spawn(5)
68-
x_rng = np.random.default_rng(x_ss)
69-
eps_rng = np.random.default_rng(eps_ss)
7084
w_rng = np.random.default_rng(w_ss)
7185
aux_rng = np.random.default_rng(aux_ss)
7286
group_rng = np.random.default_rng(group_ss)
7387

88+
# x/eps are the dominant cost; optionally fill them in parallel. w/aux/group
89+
# stay single-stream (cheap, and already batch_size-independent). n_workers==1
90+
# keeps the exact serial streams, so default output is byte-for-byte unchanged.
91+
threaded = n_workers > 1 and n_samples > 0
92+
pool = None
93+
if threaded:
94+
from concurrent.futures import ThreadPoolExecutor
95+
96+
# Fixed-size blocks (independent of Polars' batch_size) filled in parallel by one
97+
# persistent Generator per worker, so row i depends only on (seed, n_workers, fetch_size).
98+
block_rows = min(n_workers * fetch_size, n_samples)
99+
x_rngs = [np.random.default_rng(s) for s in x_ss.spawn(n_workers)]
100+
eps_rngs = [np.random.default_rng(s) for s in eps_ss.spawn(n_workers)]
101+
pool = ThreadPoolExecutor(max_workers=n_workers)
102+
x_block = eps_block = None
103+
block_pos = block_rows # trigger a fill on the first iteration
104+
else:
105+
x_rng = np.random.default_rng(x_ss)
106+
eps_rng = np.random.default_rng(eps_ss)
107+
74108
remaining_gen = n_samples
75109
remaining_deliver = n_rows if n_rows is not None else n_samples
76110

77111
chunk_idx = 0
78112
rows_left_in_chunk = int(chunk_sizes[0]) if chunk_sizes is not None and len(chunk_sizes) > 0 else 0
79113

80-
while remaining_gen > 0 and remaining_deliver > 0:
81-
if chunk_sizes is not None:
82-
k = min(bs, rows_left_in_chunk, remaining_gen)
83-
row_in_chunk = int(chunk_sizes[chunk_idx]) - rows_left_in_chunk
84-
else:
85-
k = min(bs, remaining_gen)
86-
row_in_chunk = 0
87-
x_batch = x_rng.standard_normal(size=(k, n_features))
88-
if use_weights:
89-
w_batch = w_rng.uniform(weights_low, weights_high, size=k)
90-
eps_scale = epsilon_scale / np.sqrt(w_batch)[:, None]
91-
else:
92-
w_batch = None
93-
eps_scale = epsilon_scale
94-
eps_batch = eps_rng.standard_normal(size=(k, n_responses)) * eps_scale + epsilon_loc
95-
mean_batch, extras = mean_computer(aux_rng, group_rng, x_batch, chunk_idx, row_in_chunk)
96-
y_batch = mean_batch + eps_batch
97-
98-
data: dict = {name: x_batch[:, i] for i, name in enumerate(feature_cols)}
99-
for i, name in enumerate(response_cols):
100-
data[name] = y_batch[:, i]
101-
data.update(extras)
102-
if use_weights:
103-
data["weight"] = w_batch
104-
105-
df = pl.DataFrame(data)
106-
if predicate is not None:
107-
df = df.filter(predicate)
108-
if with_columns is not None:
109-
df = df.select(with_columns)
110-
111-
if df.height > remaining_deliver:
112-
df = df.head(remaining_deliver)
113-
remaining_deliver -= df.height
114-
remaining_gen -= k # Always k, not df.height: RNG must advance by the full generated batch to keep row-i values batch-independent.
115-
116-
if chunk_sizes is not None:
117-
rows_left_in_chunk -= k
118-
if rows_left_in_chunk == 0 and chunk_idx + 1 < len(chunk_sizes):
119-
chunk_idx += 1
120-
rows_left_in_chunk = int(chunk_sizes[chunk_idx])
121-
122-
log.debug("scan_synthetic: yielded %d rows; remaining_gen=%d, remaining_deliver=%d", df.height, remaining_gen, remaining_deliver)
123-
yield df
114+
try:
115+
while remaining_gen > 0 and remaining_deliver > 0:
116+
if chunk_sizes is not None:
117+
k = min(bs, rows_left_in_chunk, remaining_gen)
118+
row_in_chunk = int(chunk_sizes[chunk_idx]) - rows_left_in_chunk
119+
else:
120+
k = min(bs, remaining_gen)
121+
row_in_chunk = 0
122+
if threaded:
123+
if block_pos == block_rows:
124+
x_block = _parallel_standard_normal(x_rngs, (block_rows, n_features), pool)
125+
eps_block = _parallel_standard_normal(eps_rngs, (block_rows, n_responses), pool)
126+
block_pos = 0
127+
k = min(k, block_rows - block_pos) # keep each batch within one fixed block
128+
x_batch = x_block[block_pos : block_pos + k]
129+
eps_raw = eps_block[block_pos : block_pos + k]
130+
block_pos += k
131+
else:
132+
x_batch = x_rng.standard_normal(size=(k, n_features))
133+
eps_raw = eps_rng.standard_normal(size=(k, n_responses))
134+
if use_weights:
135+
w_batch = w_rng.uniform(weights_low, weights_high, size=k)
136+
eps_scale = epsilon_scale / np.sqrt(w_batch)[:, None]
137+
else:
138+
w_batch = None
139+
eps_scale = epsilon_scale
140+
eps_batch = eps_raw * eps_scale + epsilon_loc
141+
mean_batch, extras = mean_computer(aux_rng, group_rng, x_batch, chunk_idx, row_in_chunk)
142+
y_batch = mean_batch + eps_batch
143+
144+
data: dict = {name: x_batch[:, i] for i, name in enumerate(feature_cols)}
145+
for i, name in enumerate(response_cols):
146+
data[name] = y_batch[:, i]
147+
data.update(extras)
148+
if use_weights:
149+
data["weight"] = w_batch
150+
151+
df = pl.DataFrame(data)
152+
if predicate is not None:
153+
df = df.filter(predicate)
154+
if with_columns is not None:
155+
df = df.select(with_columns)
156+
157+
if df.height > remaining_deliver:
158+
df = df.head(remaining_deliver)
159+
remaining_deliver -= df.height
160+
remaining_gen -= k # Always k, not df.height: RNG must advance by the full generated batch to keep row-i values batch-independent.
161+
162+
if chunk_sizes is not None:
163+
rows_left_in_chunk -= k
164+
if rows_left_in_chunk == 0 and chunk_idx + 1 < len(chunk_sizes):
165+
chunk_idx += 1
166+
rows_left_in_chunk = int(chunk_sizes[chunk_idx])
167+
168+
log.debug("scan_synthetic: yielded %d rows; remaining_gen=%d, remaining_deliver=%d", df.height, remaining_gen, remaining_deliver)
169+
yield df
170+
finally:
171+
if pool is not None:
172+
pool.shutdown(wait=False)
124173

125174
return register_io_source_with_is_pure(
126175
source_generator, schema=schema, is_pure=seed is not None, explain_name=explain_name, explain_detail=explain_detail
@@ -163,6 +212,7 @@ def scan_synthetic_regression(
163212
chunk_key: str | None = None,
164213
n_chunks: int | None = None,
165214
seed: int | None = None,
215+
n_workers: int = 1,
166216
fetch_size: int = 10_000,
167217
description: str | None = None,
168218
) -> pl.LazyFrame:
@@ -191,6 +241,7 @@ def scan_synthetic_regression(
191241
chunk_key: If provided together with ``n_chunks``, emits a monotonic ``Int64`` column with this name whose values are ``0, 1, ..., n_chunks - 1``. Must not collide with a generated column name.
192242
n_chunks: Number of contiguous chunks to split ``n_samples`` into. Required when ``chunk_key`` is set. Must satisfy ``1 <= n_chunks <= n_samples``.
193243
seed: Seed for ``np.random.default_rng``. If None, uses fresh entropy per call (and the source is registered with ``is_pure=False``).
244+
n_workers: Number of threads used to fill the ``x``/``y`` Gaussian draws (the dominant cost) in parallel. ``1`` (default) keeps the fully serial path and its exact output. With ``n_workers > 1`` the draws are generated in fixed-size blocks split across threads, so reproducibility is keyed on ``(seed, n_workers, fetch_size)`` and remains independent of the ``batch_size`` Polars chooses; values differ from the serial path. Peak generation memory grows with ``n_workers`` (a block holds ``n_workers * fetch_size`` rows). Most useful when ``n_samples`` is large.
194245
fetch_size: Default number of rows generated per batch when Polars does not provide a ``batch_size``. Must be >= 1. Defaults to 10_000.
195246
description: Optional free-form description of this source instance, attached to its OpenTelemetry span (``explain_detail``).
196247
"""
@@ -260,6 +311,7 @@ def mean_computer(
260311
mean_computer=mean_computer,
261312
extras_schema=extras_schema,
262313
chunk_sizes=chunk_sizes,
314+
n_workers=n_workers,
263315
explain_name="scan_synthetic_regression",
264316
explain_detail=description,
265317
)
@@ -282,6 +334,7 @@ def scan_synthetic_panel(
282334
epsilon_loc: float = 0.0,
283335
epsilon_scale: float = 1.0,
284336
seed: int | None = None,
337+
n_workers: int = 1,
285338
fetch_size: int = 10_000,
286339
description: str | None = None,
287340
) -> pl.LazyFrame:
@@ -310,6 +363,7 @@ def scan_synthetic_panel(
310363
epsilon_loc: Mean of the Gaussian noise. Defaults to 0.0.
311364
epsilon_scale: When ``use_weights=False``, the noise stddev for every row. When ``use_weights=True``, the *reference* stddev at ``w=1``; actual per-row noise is ``N(loc, (epsilon_scale/√w)²)``. Must be >= 0. Defaults to 1.0.
312365
seed: Seed for ``np.random.default_rng``. If None, uses fresh entropy per call (and the source is registered with ``is_pure=False``).
366+
n_workers: Number of threads used to fill the ``x``/``y`` Gaussian draws (the dominant cost) in parallel. ``1`` (default) keeps the fully serial path and its exact output. With ``n_workers > 1`` the draws are generated in fixed-size blocks split across threads, so reproducibility is keyed on ``(seed, n_workers, fetch_size)`` and remains independent of the ``batch_size`` Polars chooses; values differ from the serial path. Peak generation memory grows with ``n_workers`` (a block holds ``n_workers * fetch_size`` rows). For panels the per-date batch is ``n_symbols`` rows, so parallelism only helps when ``n_symbols`` is large.
313367
fetch_size: Default number of rows generated per batch when Polars does not provide a ``batch_size``. Must be >= 1. Defaults to 10_000.
314368
description: Optional free-form description of this source instance, attached to its OpenTelemetry span (``explain_detail``).
315369
"""
@@ -446,6 +500,7 @@ def mean_computer(
446500
mean_computer=mean_computer,
447501
extras_schema=extras_schema,
448502
chunk_sizes=chunk_sizes,
503+
n_workers=n_workers,
449504
explain_name="scan_synthetic_panel",
450505
explain_detail=description,
451506
)

polars_io_tools/tests/io_sources/test_lazy_data_generator.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,105 @@ def test_panel_betas_1d_promotion():
342342
X = df.select("x0", "x1", "x2").to_numpy()
343343
y_expected = X @ np.array([1.0, 2.0, 3.0])
344344
assert np.allclose(df["y0"].to_numpy(), y_expected, atol=1e-12)
345+
346+
347+
def test_n_workers_determinism_and_schema_parity():
348+
# Fixed (seed, n_workers) reproduces exactly; schema/shape match the serial path.
349+
kwargs = {"n_samples": 12_345, "n_features": 6, "n_responses": 2, "use_weights": True, "seed": 7}
350+
serial = scan_synthetic_regression(**kwargs, n_workers=1).collect()
351+
a = scan_synthetic_regression(**kwargs, n_workers=4).collect()
352+
b = scan_synthetic_regression(**kwargs, n_workers=4).collect()
353+
assert a.equals(b)
354+
assert a.schema == serial.schema
355+
assert a.shape == serial.shape
356+
# Threading changes the RNG stream layout, so values differ from the serial path.
357+
assert not a.equals(serial)
358+
359+
360+
def test_n_workers_batch_size_independence():
361+
# With n_workers > 1, reproducibility is keyed on (seed, n_workers, fetch_size): blocks are
362+
# sized n_workers * fetch_size, so fetch_size is part of the contract (like seed). What must
363+
# still hold for is_pure is independence from the *runtime* batch_size Polars chooses at a
364+
# fixed fetch_size -- verified here by comparing the in-memory and streaming engines.
365+
kwargs = {"n_samples": 4000, "n_features": 4, "seed": 42, "n_workers": 8, "fetch_size": 512}
366+
a = scan_synthetic_regression(**kwargs).collect()
367+
b = scan_synthetic_regression(**kwargs).collect(engine="streaming")
368+
assert a.equals(b)
369+
370+
371+
def test_n_workers_beta_recovery():
372+
betas = np.array([1.0, -2.0, 0.5, 3.0])
373+
df = scan_synthetic_regression(n_samples=200_000, n_features=4, n_responses=1, betas=betas, epsilon_scale=0.1, seed=3, n_workers=8).collect()
374+
features = df.select("x0", "x1", "x2", "x3").to_numpy()
375+
labels = df.select("y0").to_numpy()
376+
fitted = LinearRegression().fit(features, labels).coef_
377+
assert np.max(np.abs(fitted.reshape(-1) - betas)) < 0.02
378+
379+
380+
def test_n_workers_pushdowns():
381+
kwargs = {"n_samples": 5000, "n_features": 5, "n_responses": 2, "seed": 1, "n_workers": 8}
382+
assert scan_synthetic_regression(**kwargs).head(321).collect().height == 321
383+
assert scan_synthetic_regression(**kwargs).select(["x0", "y1"]).collect().columns == ["x0", "y1"]
384+
385+
386+
def test_n_workers_panel_parity():
387+
kwargs = {
388+
"start_date": date(2020, 1, 1),
389+
"end_date": date(2020, 2, 1),
390+
"n_symbols": 500,
391+
"n_features": 4,
392+
"seed": 5,
393+
}
394+
serial = scan_synthetic_panel(**kwargs, n_workers=1).collect()
395+
a = scan_synthetic_panel(**kwargs, n_workers=4).collect()
396+
b = scan_synthetic_panel(**kwargs, n_workers=4).collect()
397+
assert a.equals(b)
398+
assert a.schema == serial.schema
399+
assert a.shape == serial.shape
400+
401+
402+
def test_n_workers_invalid():
403+
with pytest.raises(ValueError, match="n_workers must be >= 1"):
404+
scan_synthetic_regression(n_samples=10, n_features=2, seed=1, n_workers=0)
405+
with pytest.raises(ValueError, match="n_workers must be >= 1"):
406+
scan_synthetic_panel(start_date=date(2020, 1, 1), end_date=date(2020, 1, 3), n_features=2, seed=1, n_workers=0)
407+
408+
409+
def test_n_workers_multi_block_batch_size_independence():
410+
# n_samples spans several fixed blocks (block_rows = n_workers * fetch_size = 4 * 250 = 1000),
411+
# exercising the block-cursor refill and boundary cap. Output must be identical whether Polars
412+
# drives it in memory or via the streaming engine (different runtime batch sizes) at fixed
413+
# fetch_size, and must not depend on that batch size.
414+
kwargs = {"n_samples": 3300, "n_features": 3, "n_responses": 2, "use_weights": True, "seed": 9, "n_workers": 4, "fetch_size": 250}
415+
a = scan_synthetic_regression(**kwargs).collect()
416+
b = scan_synthetic_regression(**kwargs).collect(engine="streaming")
417+
assert a.equals(b)
418+
assert a.height == 3300
419+
420+
421+
def test_n_workers_panel_batch_size_independence_full():
422+
# Threaded panel with categories, group_by, and weights: x/eps come from the block filler
423+
# while aux/group/weight stay per-batch serial streams -- verify they stay row-aligned and
424+
# the whole frame is independent of the runtime batch_size (in-memory vs streaming engine).
425+
kwargs = {
426+
"start_date": date(2020, 1, 1),
427+
"end_date": date(2020, 3, 1),
428+
"n_symbols": 137, # not a divisor of block_rows, so date chunks straddle blocks
429+
"n_features": 4,
430+
"categories": [["A", "B", "C"]],
431+
"group_by": ("g", ["x", "y"]),
432+
"use_weights": True,
433+
"seed": 21,
434+
"n_workers": 8,
435+
"fetch_size": 64,
436+
}
437+
a = scan_synthetic_panel(**kwargs).collect()
438+
b = scan_synthetic_panel(**kwargs).collect(engine="streaming")
439+
assert a.equals(b)
440+
441+
442+
def test_n_workers_head_pushdown_terminates_early():
443+
# An early n_rows pushdown must return promptly and shut its pool down via the generator's
444+
# finally, without generating the full declared dataset.
445+
df = scan_synthetic_regression(n_samples=50_000_000, n_features=4, seed=1, n_workers=4).head(5).collect()
446+
assert df.height == 5

0 commit comments

Comments
 (0)