Skip to content

Increment 38: --threads and --memory-budget as settings, no core count hardcoded - #239

Merged
skavhaug merged 15 commits into
masterfrom
worktree-threads
Oct 13, 2026
Merged

skavhaug merged 15 commits into
masterfrom
worktree-threads

Conversation

@skavhaug

Copy link
Copy Markdown
Member

Increment 38, docs/increments/38-threads-and-memory.md (section 12 is the as-built record), the first fix from the generality audit. 96 net production lines by python3 tools/count_loc.py 5d2e706d HEAD (237 added, 141 removed).

  • New tin_engine/resources.py: one Resources model, the only place that reads the machine. Threads from --threads (0 = all cores), RASPUTIN_THREADS as the default, else the machine's cores with a container's cpu.max quota counted; a memory budget from --memory-budget, with a container's memory.max read when present.
  • --threads and --memory-budget on mesh, catchment and station-catchments. The count reaches all five C++ entry points and the three Python pools; the reprojector's fixed cap of 8 threads is gone. The flood and mosaic memory caps are overridable, and refusals name --memory-budget and the figure.
  • The stats file prints "Threads used: N." and the run record gains a threads entry. The unused decompose.py is deleted. mcp/schema.py keeps a zero lower bound (one line).

Evidence: design approved after two review rounds; red 4b9c0dfe (110 tests), green b252a70d, test fixes 9de3212c; full suite 7,422 passed. @perf's quick check against a baseline refreshed on an idle machine: NO CHANGE, meshes identical on every case. Code review rounds 1 and 2, APPROVED.

🤖 Generated with Claude Code

skavhaug and others added 12 commits October 12, 2026 17:14
The first fix from the generality audit (rows D1, D2, D3, D5, D6, D7;
section 6 item 1), as docs/increments/38-threads-and-memory.md plus the
ROADMAP row. Design only; no production code.

What it settles:
- `--threads N` on mesh, catchment and station-catchments; 0 = every core;
  RASPUTIN_THREADS is the default when the option is absent (0 there means
  every core too); a count above the cores is honoured, not clamped (OpenMP
  and Rayon honour it; the benchmark sweeps to 20 on 10 cores).
- One `Resources` model (threads, memory_budget), built once in cli.py from
  the options and os.environ, carried to open_dem and delineate; the leaf
  functions take the one integer they use. The C++ keeps a plain integer and
  its 0 default; no C++ file changes.
- Pools default to one worker; resources.py is the only module that reads
  the machine (a test greps for it). The reprojector's cap of 8 goes; the
  reprojector inside the target grid's workers runs serial instead of
  opening a pool inside a pool.
- `--memory-budget 8G` caps the mosaic canvas and the flood window; default
  half of min(physical, cgroup limit) with memory.max (v2) and
  memory/memory.limit_in_bytes (v1) read from a cgroup root the tests can
  point at a synthetic directory; cpu.max likewise for the core count.
- Stats row "Threads used: N." for every DEM run; run record entry
  `threads`.
- decompose.py and its test deleted; project_structure.md row swapped for
  resources.py.

Measured on 5d2e706: five _core entry points take threads and five call
sites reach them, four passing nothing; three pools and four core-count
reads plus one memory read (the greps and their output are in section 3).
Estimate 140 added, 78 removed, net about 60 (the audit said 130 before
counting call sites); worst case +39 % about 120 net.

Prior art read on 2026-10-12: OpenMP 5.0 section 6.2, GNU nproc, Rayon's
ThreadPoolBuilder, threadpoolctl, Python 3.13's os.process_cpu_count, the
JDK's UseContainerSupport (JDK-8146115), the kernel's cgroup-v2 guide.
Legacy greps against legacy-archive: nothing on threads; one `#include
<memory.h>` on memory. Nothing carried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… rulings recorded (@architect)

Design review round 1 (recorded at 3ff4843) asked for five fixes and three
wordings; all are in docs/increments/38-threads-and-memory.md, cites pinned
at 5d2e706 with directory paths. python3 tools/check_citations.py: exit 0,
46 at-risk lines, all ROADMAP.md rows, none in this file.

(1) T10: the figures now come from a run, not the test's comment. Probe:
delineate on tests/python/test_catchment.py:296-304's fixture with
physical_memory faked to twice each budget. The run floods three windows,
49 x 49, 113 x 95 and 224 x 175 nodes; after each flood the growth check
plans a probe window at the base margin (catchment.py:279), judged by the
mosaic cap. Measured: 8192 refused by the mosaic cap ("49 x 49, 9604 bytes
at float32"); 9604, 13824 and 14405 refused by the flood cap ("14406 bytes
to flood"); 14406 floods the first window and is refused on the probe
window ("73 x 55, 16060 bytes"); 235199 refused on the last window
("224 x 175, 235200 bytes to flood"); 235200 floods. The tests pin 12000
(flood cap), 14406 (names 73 x 55, so the first window passed exactly at
its figure), 235199 and 235200 (exactly at the budget, three windows); the
8K CLI case is the mosaic refusal naming 49 x 49, 9604 and 8192. The
reviewer's "14406 for exactly at" would still raise, on the second window;
hence the extra pair at 235200.

(2) T11: tests/python/test_layering.py:44's "decompose" row goes and a
"resources" row (L1, no first-party import) comes in the red commit; :153
fails on the stale row, :152 on the missing one.

(3) T8: one spy per large reprojector site: feature_input.reprojector on
test_cli_mesh_features.py:328-338 (the EPSG:4326 GeoJSON over the
EPSG:25833 DEM, the one fixture whose feature CRS differs), asserting
threads=3 on the call whose source CRS is the file's;
land_cover_fractions.reprojector on the fractions fixture (built on every
run, the class raster being EPSG:4326). Five tests plus two defaults.

(4) Reprojector callers: 15, the grep pasted with its file list. Four sites
move large arrays at three places; target_grid.py:239 moves a block's rim
and then at most 512 x 512 = 262 144 valid nodes (:32, :238, :252), under
the 500 000 threshold (crs.py:70-73), so only :181 nests a pool today. The
other eleven listed by line with what each moves.

(5) cpu.max: the memory rule. A quota counts only when the file holds two
words, both positive whole numbers (max is no quota; a zero or missing
period is no quota, never a division); section 6 and T3 list the malformed
files, each giving the plain count, never an exception.

Wordings: the two refusals read "(set with --memory-budget; half the
machine's memory when it is not set)", true with or without a budget;
section 4.5 says bench.py's child prints "Threads used: 10." whatever the
sweep's count, since its monkeypatch overrides refine only; T9 names
DerivedCommand.argv(model) (mcp/schema.py:108).

Section 10 is now "Ola's rulings": "Yes to all", 2026-10-12, taking each
default (mesh gets --memory-budget; a count above the cores is honoured;
a container's CPU quota counts toward every core; the row reads "Threads
used: 3."), with sections 4.1-4.4 and 7 pointing at it. Status: designed,
review round 1 fixes in; next round 2. Estimate unmoved at about 60 net.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The failing suite of docs/increments/38-threads-and-memory.md, section 8,
before any production code. 110 tests are red, 85 of them in two new files;
every one fails on a missing module, option, keyword or wording, none on a
typo (the red run's distinct reasons: 65 "No module named
tin_engine.resources", 19 "No such option: --threads" or "--memory-budget",
7 "plan_mosaic() got an unexpected keyword argument 'memory_budget'", 2
"reprojector() got an unexpected keyword argument 'threads'", the rest the
old "Threads: 10 (hardware concurrency)" wording, the cap of 8 workers, the
block decoder's default of 10 workers, crs.WORKERS still present, the
layering table's missing row, decompose still importable, and the five
modules that still read the machine). The 606 other tests in the touched
files pass; the full Python suite passes apart from the red tests.

New files:

- tests/python/test_resources.py (T1 to T5, T11): the grep-equivalent that
  only resources.py holds cpu_count, sched_getaffinity, sysconf or
  /sys/fs/cgroup; the Resources model (frozen, never 0; fallback() is one
  thread and half the memory); the threads rule of section 4.2, one case
  each, with 1.5 and 0x10 added to abc and -1 as non-whole numbers;
  machine_cores on synthetic cpu.max files (2 CPUs, 0.5 rounds up to 1,
  1.5 to 2, and every malformed case of section 6 plus an unreadable file,
  skipped as root); machine_memory on synthetic memory.max and v1
  memory.limit_in_bytes (1 GiB and 512 MiB, under the machine as asserted;
  v1's 2^63 loses to the physical figure; v2 wins over v1; max, 0, -1, text
  and an empty file are no limit); parse_bytes and the default budget; and
  find_spec("tin_engine.decompose") is None. Every machine figure is
  computed from the module's own readers, never a literal.
- tests/python/test_cli_threads.py (T6 to T8, T10's command line): the
  stats row "Threads used: N." from --threads, from RASPUTIN_THREADS, from
  both, from 0 and from nothing (every core, through machine_cores()), on a
  stride run too; the --record entry threads and the Inputs row "Threads
  used"; --threads -1 and RASPUTIN_THREADS=abc or -1 refused in the CLI's
  words; the help of mesh, catchment and station-catchments lists both
  options; a catchment run with --threads 3. One spy per _core site
  (cli.refine, edge_strip.refine_strip, final_check.refine_points on the
  Velhas --out-crs run, cli.SlopeTolerance on a --tolerance-slope run,
  land_cover_fractions._core.class_areas on the fractions fixture), each
  asserting the mesh is still written. One spy per pool (io/cog.py on a
  cached source and on a tile, target_grid.py's two pools on the Velhas
  run) and per large reprojector site (target_grid.reprojector with
  threads=1 inside the workers, feature_input.reprojector on the EPSG:4326
  gallery over the EPSG:25833 DEM, picking the call from the file's CRS to
  the DEM's, land_cover_fractions.reprojector on the fractions fixture).
  --memory-budget 8K on the catchment fixture refused by the mosaic cap
  naming 49 x 49, 9604 and 8192; 8KB names the text; mesh --memory-budget 1
  refused naming 17 x 21, 1428 and the option, 8G passes. The 8K and 12000
  figures were re-measured through the CLI on test_cli_catchment's dem_dir
  with the cap faked to twice the budget before being written down.

Changed:

- test_catchment.py: the monkeypatched cap test becomes TestTheMemoryBudget
  through delineate(..., resources=Resources(threads=1, memory_budget=)):
  12000 names 49 x 49, 14406 bytes to flood and 12000; 14406 names 73 x 55,
  16060; 235199 names 224 x 175, 235200; 235200 floods three windows of the
  measured shapes; resources=None still floods. Resources is imported inside
  the helper so only these tests fail on its absence.
- test_mosaic.py: M5 on plan_mosaic(..., memory_budget=) (the plan fixture
  passes the keyword only when given); the refusal names --memory-budget;
  physical_memory's test moves to test_resources.py as T4's plain case.
- test_dem_input.py: the cap through open_dem(..., resources=).
- test_io_cog.py: the default thread count is one worker (was the cores).
- test_crs.py: crs.WORKERS is gone; the pool takes 9 workers for 9 chunks
  with threads=9 and 2 for 2 chunks; without a count no pool opens.
- test_stats.py, test_cli_mesh_stats.py: "Threads used: 10.", printed on
  every DEM run and never on a fixture run (T12).
- test_mcp_schema.py: T9, threads and memory_budget in the three tools'
  schemas and in argv.
- test_layering.py: the decompose row goes, resources joins L1.
- test_decompose_partition.py deleted (T11).

Left out, by rule: T11's assertion on project_structure.md, which is a
prose file under tools/ci_changes.py and so may not be read by a test
(conftest.py, h11 T5); @Reviewer reads its two rows instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ompose test pinned at 5d2e706, by the main session

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The minimal code for docs/increments/38-threads-and-memory.md, sections 4
to 6 and Ola's four rulings (section 10), against @tester's red commit
4b9c0df. No test file is touched.

New: src_python/tin_engine/resources.py, the one module that reads the
machine. Resources (frozen; threads >= 1, memory_budget >= 1; fallback() is
one thread and half the machine's memory); machine_cores() (the process's
count, capped by a cgroup v2 cpu.max quota of two positive whole numbers,
ceil(MAX / PERIOD); anything else, a negative quota included, is no quota);
machine_memory() (physical memory capped by memory.max, else v1
memory/memory.limit_in_bytes; "max", 0, -1, text, an empty or unreadable
file are no limit); parse_bytes() (a whole number with an optional K, M, G
or T, 1024-based; 0, "8GB", "1.5G", "G", "" refused naming the text);
resources() (section 4.2's table: --threads, else RASPUTIN_THREADS stripped
and whole, 0 = every core; --memory-budget, else half the memory).

Where the value travels: cli.py gets two Annotated aliases THREADS (min=0,
metavar N: the default "<int range>" widened Rich's metavar column and
truncated the longest option names in --help) and MEMORY_BUDGET on mesh,
catchment and station-catchments, resolves them once per command
(_resources; a refusal is a usage error naming RASPUTIN_THREADS or
--memory-budget), and passes the count to the five _core sites (refine,
SlopeTolerance, edge_strip.run -> refine_strip, final_check.run ->
refine_points, assign -> class_areas) and the three pools: repository_for
and both repositories keep `threads` for the block decoder;
open_dem(request, resources) plans every mosaic under the budget and
resamples and checks on the count; the reprojector inside target_grid's
workers takes threads=1; FeatureRequest.threads reaches _Tally's
reprojector; index_space's reprojector takes assign's count. The library
defaults are one worker (reprojector, decode_window, decode_class_window,
assign) and the C++'s 0 for the two _core wrappers. crs.WORKERS and
mosaic.physical_memory are gone; the chunked reprojector takes
min(threads, chunks) workers and opens no pool for threads=1.

The budget: plan_mosaic(..., memory_budget=None) caps the canvas (None is
Resources.fallback()'s); delineate(request, repository, resources=None)
passes it to _grow and _plan; both refusals read "over the memory budget of
B bytes (set with --memory-budget; half the machine's memory when it is
not set)". run_batch(..., resources=) hands it to each delineate.
dem_input._domain_plan's budget defaults to None (test_cli_fetch calls it
with two arguments).

The report: stats.render prints "Threads used: N."; the record gains the
Inputs entry threads ("Threads used"), set on every DEM run; a fixture run
has neither. decompose.py is deleted; mesh_index's docstring and
project_structure.md's rows follow; README and INSTALL name
RASPUTIN_THREADS and --memory-budget.

Beyond the design, because a test pins it: mcp/schema.py keeps a
constraint of 0 (`minimum: 0` for --threads; `if v` dropped it), one line,
where section 4.5 said no mcp/ change.

Not green, for @tester (the full suite: 7402 passed, 21 failed before the
help and _domain_plan fixes above, which clear 4; the 17 left are tests
that pin the old surface or stand in for a function with a narrower
signature than the design gives it): test_layering's rows for cli,
dem_input, catchment, catchment_batch and mosaic lack `resources`, which
section 4.4 has each import (5 tests); test_cli_mesh_domain_crs's
as_16_opened_it(request) stands in for open_dem(request, resources) with
one parameter (2); test_cli_mesh_plain_output wraps cli.refine with
`real(*a, **k, threads=1)`, which the CLI's own threads= keyword now
collides with (--threads 1 is the way since 38), and asserts "threads" is
not a record key, against T6 and section 4.4 (2); test_mosaic's
test_the_plan_never_loads pins plan_mosaic's parameters without
memory_budget (1); test_feature_input's fake(src, dst) reprojectors and
single_call_reprojector(src, dst) (3) and test_target_grid's
_SpiedReprojector.__call__(source, target) (4) do not take the threads=
keyword the design's call sites pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-38 surface (@tester)

After @developer's green b252a70, every red test of 4b9c0df passes but
the five layering rows, and 17 older tests failed on the test side. Each
fixed in tests/ only, as docs/increments/38-threads-and-memory.md intends:

- test_layering.py: `resources` added to the rows for cli, dem_input,
  catchment, catchment_batch and mosaic (5 tests). Checked with importscan
  against b252a70: each module's first-party import set grew by exactly
  tin_engine.resources, and resources is L1, under all five.
- test_cli_mesh_domain_crs.py: `as_16_opened_it(request, resources=None)`
  now stands in for `open_dem(request, resources)` and passes the second
  argument through (2 tests).
- test_cli_mesh_plain_output.py: the bit-identity test runs `--threads 1`
  and `--threads 2` to the same paths instead of wrapping cli.refine with
  a colliding keyword; the mesh bytes are equal, the records differ only
  in the `threads` entry (1 and 2) and the echoed command line. The
  "threads is not a record key" assertion goes, as T6 pins the entry
  (2 tests). Two imports left unused by the rewrite removed.
- test_mosaic.py: plan_mosaic's parameters gain memory_budget (1 test).
- test_feature_input.py: the two `fake(src, dst)` reprojectors and
  `single_call_reprojector` accept `threads=1` (the fakes pass it to the
  real one; the pre-35 verbatim copy ignores it) (3 tests).
- test_target_grid.py: `_SpiedReprojector.__call__` accepts `threads`
  and passes it through (4 tests).
- test_mcp_schema.py: T9's docstring no longer says "no change to mcp/";
  it names the one line kept (`minimum: 0` is a bound).

No red scaffolding of 38's remained (the `importorskip("mcp")` and the
test_mosaic docstring predate it).

Full `pytest tests/python/` with the coverage gate: 7422 passed, 1 failed,
18 skipped, 98.44% coverage (floor 85%). The one failure,
test_core_cdt.py::TestGil::test_the_ticker_distinguishes_a_held_gil_from_a_released_one
("a GIL-holding call let 7 ticks through", bound 3), ran beside the three
detached `rasputin mesh` processes; alone it passed 5 of 5 runs. Untouched
by 38; a load-sensitive bound, reported in the handback.
`ruff check tests/` and `ruff format --check tests/`: clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Status line and ROADMAP row, by the main session

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…three no-mcp-change sentences corrected, by the main session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ADMAP row, by the main session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@skavhaug
skavhaug enabled auto-merge October 12, 2026 20:46
@skavhaug
skavhaug added this pull request to the merge queue Oct 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 12, 2026
@skavhaug
skavhaug enabled auto-merge October 13, 2026 05:29
…P rows 38 and 39 both kept, by the main session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@skavhaug
skavhaug added this pull request to the merge queue Oct 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 13, 2026
skavhaug and others added 2 commits October 13, 2026 08:38
…tree-threads (@developer)

Conflicts only in src_python/tin_engine/cli.py, resolved by keeping both
sides: the feature sources use 41's loaded maps; _open_features takes the
vocabulary and then the thread count (threads: int = 1 kept last, after
41's required vocabulary); FeatureRequest gets sources, vocabulary and
threads; _dem_mesh takes threads: int = 0 then vocabulary, and its call
passes res.threads then vocabulary. feature_input.py, run_record.py and
the docs merged without conflict.

Gates: ruff check, ruff format --check, mypy clean; pytest 7554 passed,
18 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@skavhaug
skavhaug enabled auto-merge October 13, 2026 06:41
@skavhaug
skavhaug added this pull request to the merge queue Oct 13, 2026
Merged via the queue into master with commit ce8c364 Oct 13, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant