Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/pr-benchmark-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: PR Benchmark Comment

on:
workflow_run:
workflows: ["PR Benchmark"]
types: [completed]

permissions:
pull-requests: write

jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Download benchmark result
uses: actions/download-artifact@v4
with:
name: benchmark-result
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Read PR number and body
id: data
run: |
echo "pr=$(cat pr-number.txt)" >> "$GITHUB_OUTPUT"
{
echo "body<<EOF"
cat benchmark-comment.md
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Comment on PR
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.data.outputs.pr }}
message: ${{ steps.data.outputs.body }}
comment-tag: benchmark-report
25 changes: 14 additions & 11 deletions .github/workflows/pr-benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ name: PR Benchmark

permissions:
contents: read
pull-requests: write

on:
pull_request:
Expand All @@ -19,7 +18,6 @@ jobs:
contains(github.event.comment.body, 'retrigger-benchmark'))
runs-on: ubuntu-latest
steps:
# Check out the PR head so comment-triggered runs benchmark the PR, not main.
- uses: actions/checkout@v6
with:
ref: refs/pull/${{ github.event.pull_request.number || github.event.issue.number }}/head
Expand All @@ -31,8 +29,7 @@ jobs:
gui-deps: "false"

- name: Create dummy Minecraft world directory
run: |
mkdir -p "./world/region"
run: mkdir -p "./world/region"

- name: Build for release
run: cargo build --release --no-default-features
Expand Down Expand Up @@ -79,7 +76,6 @@ jobs:
diff=$((duration - baseline_time))
abs_diff=${diff#-}

# Use generation-only time for verdict when available
if [ -n "$gen_time" ]; then
eval_diff=$((gen_time - baseline_time))
else
Expand Down Expand Up @@ -146,10 +142,17 @@ jobs:
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Comment build time on PR
uses: thollander/actions-comment-pull-request@v3
- name: Save benchmark result to files
run: |
cat > benchmark-comment.md <<'COMMENT_EOF'
${{ steps.comment_body.outputs.summary }}
COMMENT_EOF
echo "${{ github.event.pull_request.number || github.event.issue.number }}" > pr-number.txt

- name: Upload benchmark result
uses: actions/upload-artifact@v4
with:
message: ${{ steps.comment_body.outputs.summary }}
comment-tag: benchmark-report
env:
GITHUB_TOKEN: ${{ secrets.BENCHMARK_TOKEN }}
name: benchmark-result
path: |
benchmark-comment.md
pr-number.txt
17 changes: 17 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ pub struct Args {
#[arg(long, default_value_t = 1.0)]
pub scale: f64,

/// Percentage of CPU cores to use for world generation (10-100).
/// Higher values spawn more worker threads: faster generation, but the
/// system stays less responsive. Overridden by the RAYON_NUM_THREADS env var.
#[arg(long, default_value_t = 90)]
pub cpu_percent: u8,

/// Overlap each tile batch's merge with the next batch's placement so worker
/// cores aren't idle during merges (faster on large areas; output unchanged).
/// Also enabled by the ARNIS_PIPELINE_MERGE env var.
#[arg(long, default_value_t = false)]
pub pipeline_merge: bool,

/// Projection mode for coordinate mapping
/// local: each generation starts at Minecraft (0,0) (default)
/// web_mercator: global projection for multi-generation worlds
Expand Down Expand Up @@ -194,6 +206,11 @@ pub fn validate_args(args: &Args) -> Result<(), String> {
return Err("Rotation angle must be between -90 and 90 degrees.".to_string());
}

// Validate CPU usage percentage
if args.cpu_percent < 10 || args.cpu_percent > 100 {
return Err("CPU usage percent (--cpu-percent) must be between 10 and 100.".to_string());
}

Ok(())
}

Expand Down
142 changes: 131 additions & 11 deletions src/data_processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::progress::{emit_gui_progress_update, emit_gui_progress_update_ex, emi
#[cfg(feature = "gui")]
use crate::telemetry::{send_log, LogLevel};
use crate::tile;
use crate::world_editor::{FlushWorker, WorldEditor, WorldFormat};
use crate::world_editor::{FlushWorker, WorldEditor, WorldFormat, WorldToModify};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use rayon::prelude::*;
Expand Down Expand Up @@ -253,6 +253,18 @@ fn should_stream_to_disk(num_regions: usize) -> bool {
}

/// Generate world with explicit format options (used by GUI for Bedrock support)
/// Public entry point. Runs the whole generation inside a dedicated rayon
/// thread pool sized to the user's CPU budget (`args.cpu_percent`).
///
/// A *scoped* pool is used instead of reconfiguring the global one because
/// rayon's global pool can only be built once per process (it's already built
/// at startup), so a per-run scoped pool is the only way to let the setting
/// take effect on every generation without restarting the app. Every nested
/// `par_iter` inside `generate_world_inner` (tile placement, region saving,
/// flood-fill precompute, …) automatically runs on this pool via `install`.
///
/// `RAYON_NUM_THREADS`, if set, wins (power-user / benchmark override). If the
/// pool can't be built we fall back to running on the global pool.
pub fn generate_world_with_options(
elements: Vec<ProcessedElement>,
xzbbox: XZBBox,
Expand All @@ -261,6 +273,64 @@ pub fn generate_world_with_options(
args: &Args,
options: GenerationOptions,
outline_suppression: OutlineSuppression,
) -> Result<PathBuf, String> {
let target_threads = generation_thread_count(args.cpu_percent);

match rayon::ThreadPoolBuilder::new()
.num_threads(target_threads)
.thread_name(|i| format!("arnis-gen-{i}"))
.build()
{
Ok(pool) => pool.install(move || {
generate_world_inner(
elements,
xzbbox,
llbbox,
ground,
args,
options,
outline_suppression,
)
}),
Err(_) => generate_world_inner(
elements,
xzbbox,
llbbox,
ground,
args,
options,
outline_suppression,
),
}
}

/// Resolve the worker-thread count for generation from a CPU percentage.
/// `RAYON_NUM_THREADS` overrides the percentage when set; otherwise the count
/// is `round(cores * percent/100)`, clamped to at least 1.
fn generation_thread_count(cpu_percent: u8) -> usize {
if let Some(n) = std::env::var("RAYON_NUM_THREADS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
{
return n;
}

let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
let fraction = f64::from(cpu_percent.clamp(10, 100)) / 100.0;
((cores as f64 * fraction).round() as usize).max(1)
}

fn generate_world_inner(
elements: Vec<ProcessedElement>,
xzbbox: XZBBox,
llbbox: LLBBox,
ground: Ground,
args: &Args,
options: GenerationOptions,
outline_suppression: OutlineSuppression,
) -> Result<PathBuf, String> {
let output_path = options.path.clone();
let world_format = options.format;
Expand Down Expand Up @@ -461,10 +531,12 @@ pub fn generate_world_with_options(
let total_tiles = indexed_tiles.len().max(1);
let mut tiles_merged = 0usize;
let mut last_emitted_pct = 20.0_f64;
for batch in indexed_tiles.chunks(tile_batch_size) {
// Phase 1: process this batch of tiles in parallel
let place_start = std::time::Instant::now();
let batch_results: Vec<_> = batch

// Phase 1 (parallel): place one batch of tiles into independent tile editors.
// Pure function of immutable inputs — never touches the shared `editor`, so it
// can run concurrently with a Phase-2 merge of an earlier batch.
let place_batch = |batch: &[(usize, &tile::TileBounds)]| {
batch
.par_iter()
.map(|&(tile_idx, tile_bounds)| {
// max_* are exclusive; rect_from_min_max treats max as inclusive,
Expand Down Expand Up @@ -568,12 +640,21 @@ pub fn generate_world_with_options(
tile_road_overrides,
)
})
.collect();
place_dur += place_start.elapsed();
.collect::<Vec<_>>()
};

let merge_start = std::time::Instant::now();
// Phase 2: merge this batch's results into the main editor (sequential).
// batch_results is dropped after this loop, freeing memory before next batch.
// Phase 2 (sequential): merge a batch's tile results into the shared `editor`,
// in the batch's authoritative order. This is the only writer of `editor` and
// the eviction bookkeeping, so the merge order is fixed regardless of whether a
// placement is overlapping — output stays bit-exact.
#[allow(clippy::type_complexity)]
let mut merge_batch = |batch_results: Vec<(
usize,
WorldToModify,
Vec<(i32, i32)>,
fnv::FnvHashMap<(i32, i32), i32>,
)>|
-> Result<(), String> {
for (tile_idx, tile_world, tile_subway_pts, tile_road_overrides) in batch_results {
editor.merge_world(
tile_world,
Expand Down Expand Up @@ -632,7 +713,46 @@ pub fn generate_world_with_options(
last_emitted_pct = pct;
}
}
merge_dur += merge_start.elapsed();
Ok(())
};

// Opt-in (GUI "Faster merge" toggle / --pipeline-merge / ARNIS_PIPELINE_MERGE env):
// overlap the serial merge of batch N with the parallel placement of batch N+1 via
// rayon::join, so worker cores aren't idle during merges. Merge order is unchanged
// (in-order, batch by batch), so output is bit-exact vs. the default path — verify
// with --benchmark + ARNIS_BLOCK_HASH.
let pipeline_merge =
args.pipeline_merge || std::env::var_os("ARNIS_PIPELINE_MERGE").is_some();
if pipeline_merge {
let loop_start = std::time::Instant::now();
let mut pending: Option<Vec<_>> = None;
for batch in indexed_tiles.chunks(tile_batch_size) {
if let Some(prev) = pending.take() {
// Place this batch while the previous batch merges.
let (this_results, merge_res) =
rayon::join(|| place_batch(batch), || merge_batch(prev));
merge_res?;
pending = Some(this_results);
} else {
pending = Some(place_batch(batch));
}
}
if let Some(prev) = pending.take() {
merge_batch(prev)?;
}
// Overlapped: report wall-clock as placement, merge folded in.
place_dur = loop_start.elapsed();
} else {
// Default: place a batch, then merge it, repeat (cores idle during merge).
for batch in indexed_tiles.chunks(tile_batch_size) {
let place_start = std::time::Instant::now();
let batch_results = place_batch(batch);
place_dur += place_start.elapsed();

let merge_start = std::time::Instant::now();
merge_batch(batch_results)?;
merge_dur += merge_start.elapsed();
}
}
bench.report("element_placement", place_dur);
bench.report("tile_merge", merge_dur);
Expand Down
4 changes: 4 additions & 0 deletions src/gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,8 @@ fn gui_start_generation(
telemetry_consent: bool,
world_format: String,
rotation_angle: f64,
cpu_percent: u8,
pipeline_merge: bool,
) -> Result<(), String> {
use progress::emit_gui_error;
use LLBBox;
Expand Down Expand Up @@ -1103,6 +1105,8 @@ fn gui_start_generation(
luanti: world_format == WorldFormat::LuantiWorld,
downloader: "requests".to_string(),
scale: world_scale,
cpu_percent: cpu_percent.clamp(10, 100),
pipeline_merge,
projection: crate::projection::ProjectionKind::Local,
ground_level,
terrain: terrain_enabled,
Expand Down
24 changes: 24 additions & 0 deletions src/gui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ <h2 data-localize="customization_settings">Settings</h2>
</div>

<div class="settings-scrollable">
<!-- Performance (top-level, not in a section) -->
<!-- CPU Usage Slider -->
<div class="settings-row">
<label for="cpu-usage-slider">
<span data-localize="cpu_usage">CPU Usage</span>
<span class="tooltip-icon" data-tooltip="Percentage of CPU cores used during world generation. Higher = more worker threads and faster generation, but a less responsive system. Double-click to reset.">?</span>
</label>
<div class="settings-control">
<input type="range" id="cpu-usage-slider" name="cpu-usage-slider" min="10" max="100" step="1" value="90">
<span id="cpu-usage-value">90%</span>
</div>
</div>

<!-- Faster Merge (pipelined) Toggle -->
<div class="settings-row">
<label for="pipeline-merge-toggle">
<span data-localize="pipeline_merge">Faster merge (beta)</span>
<span class="tooltip-icon" data-tooltip="Overlap each tile batch's merge with the next batch's placement so CPU cores aren't idle during merges. Faster on large areas; output is identical. Experimental.">?</span>
</label>
<div class="settings-control">
<input type="checkbox" id="pipeline-merge-toggle" name="pipeline-merge-toggle">
</div>
</div>

<!-- Section: Generation -->
<div class="settings-section-header" data-localize="settings_section_generation">Generation</div>

Expand Down
Loading
Loading