diff --git a/.gitignore b/.gitignore index b1dff0d..9c91ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +output/* +benchmark-results/* +plots/* ### IntelliJ IDEA ### .idea/modules.xml @@ -42,4 +45,4 @@ bin/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 9d62f93..2a65317 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,10 +1,16 @@ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..81d0a37 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Анализ производительности +Эксперимент проводился на машине со следующими характеристиками: macOS 15.7.7, MacBook Air, Apple M3 CPU с 8 ядрами, 16 GB LPDDR5 RAM, Apple M3 GPU с 8 ядрами. macOS фиксированную частоту CPU не показывает. + +## Результаты измерений + +Ядро свёртки: `gaussian3`. + +Размер изображения fox.bmp: 2464 x 1412 пикселей. + +| strategy | partition_kind | partition_value | threads | avg_ms | min_ms | max_ms | stddev_ms | ci95_low_ms | ci95_high_ms | throughput_mp_s | throughput_ci95_low_mp_s | throughput_ci95_high_mp_s | +|------------|----------------|-----------------|---------|------------|------------|------------|-----------|-------------|--------------|-----------------|--------------------------|---------------------------| +| sequential | none | - | 1 | 35.706878 | 34.784208 | 37.341875 | 0.758688 | 35.322929 | 36.090827 | 97.477332 | 96.445753 | 98.508912 | +| pixels | none | - | 1 | 38.955314 | 38.332459 | 40.153666 | 0.439817 | 38.732736 | 39.177892 | 89.322240 | 88.819319 | 89.825161 | +| rows | none | - | 1 | 35.227875 | 34.960041 | 35.719541 | 0.212832 | 35.120167 | 35.335583 | 98.765140 | 98.464459 | 99.065822 | +| columns | none | - | 1 | 39.751442 | 39.227875 | 40.233417 | 0.286755 | 39.606323 | 39.896560 | 87.527315 | 87.207807 | 87.846824 | +| grid | grid | 2x2 | 1 | 35.236372 | 34.970125 | 35.804875 | 0.249382 | 35.110168 | 35.362577 | 98.742557 | 98.391473 | 99.093640 | +| grid | grid | 4x4 | 1 | 35.559728 | 35.198875 | 37.073000 | 0.495082 | 35.309182 | 35.810274 | 97.857317 | 97.187501 | 98.527132 | +| grid | tile | 32x32 | 1 | 36.646395 | 35.969125 | 37.088916 | 0.311479 | 36.488765 | 36.804024 | 94.945340 | 94.533800 | 95.356881 | +| grid | tile | 64x64 | 1 | 35.720978 | 35.420000 | 36.130958 | 0.184447 | 35.627635 | 35.814321 | 97.400867 | 97.147436 | 97.654298 | +| grid | tile | 128x128 | 1 | 35.391764 | 35.250333 | 35.690833 | 0.156303 | 35.312664 | 35.470864 | 98.306235 | 98.087437 | 98.525034 | +| pixels | none | - | 2 | 106.870856 | 99.278542 | 117.567000 | 4.554990 | 104.565712 | 109.175999 | 32.609167 | 31.917432 | 33.300902 | +| rows | none | - | 2 | 18.318461 | 18.148250 | 18.669375 | 0.178265 | 18.228247 | 18.408676 | 189.943501 | 189.016865 | 190.870137 | +| columns | none | - | 2 | 22.482153 | 21.733333 | 23.283625 | 0.474377 | 22.242085 | 22.722221 | 154.817026 | 153.157035 | 156.477017 | +| grid | grid | 2x2 | 2 | 18.286422 | 18.093167 | 18.627292 | 0.203578 | 18.183397 | 18.389447 | 190.281519 | 189.215578 | 191.347461 | +| grid | grid | 4x4 | 2 | 18.261758 | 18.065584 | 18.745792 | 0.215349 | 18.152777 | 18.370740 | 190.541079 | 189.415229 | 191.666929 | +| grid | tile | 32x32 | 2 | 19.121575 | 18.890834 | 19.631333 | 0.216260 | 19.012132 | 19.231018 | 181.971307 | 180.943323 | 182.999291 | +| grid | tile | 64x64 | 2 | 18.507408 | 18.395625 | 18.822333 | 0.130973 | 18.441127 | 18.573690 | 187.996550 | 187.330276 | 188.662824 | +| grid | tile | 128x128 | 2 | 18.237692 | 18.126250 | 18.552500 | 0.149770 | 18.161898 | 18.313486 | 190.779883 | 189.995350 | 191.564416 | +| pixels | none | - | 4 | 123.311592 | 119.402875 | 133.228750 | 3.485566 | 121.547652 | 125.075531 | 28.234697 | 27.845904 | 28.623490 | +| rows | none | - | 4 | 9.509681 | 9.402167 | 9.837625 | 0.159100 | 9.429165 | 9.590196 | 365.948807 | 362.921513 | 368.976101 | +| columns | none | - | 4 | 11.534620 | 11.386459 | 11.899417 | 0.178398 | 11.444338 | 11.624902 | 301.694633 | 299.369870 | 304.019397 | +| grid | grid | 2x2 | 4 | 9.639872 | 9.435375 | 10.236958 | 0.280458 | 9.497941 | 9.781804 | 361.190220 | 356.047826 | 366.332614 | +| grid | grid | 4x4 | 4 | 9.549867 | 9.439958 | 9.867000 | 0.160937 | 9.468422 | 9.631312 | 364.410330 | 361.371091 | 367.449569 | +| grid | tile | 32x32 | 4 | 9.963600 | 9.803458 | 10.268583 | 0.157785 | 9.883750 | 10.043450 | 349.268477 | 346.507582 | 352.029373 | +| grid | tile | 64x64 | 4 | 9.713842 | 9.553750 | 10.400125 | 0.241588 | 9.591581 | 9.836102 | 358.364671 | 354.032386 | 362.696957 | +| grid | tile | 128x128 | 4 | 9.655564 | 9.470958 | 9.937917 | 0.121603 | 9.594024 | 9.717104 | 360.380848 | 358.096359 | 362.665337 | +| pixels | none | - | 8 | 161.366297 | 158.185500 | 165.082833 | 2.538605 | 160.081586 | 162.651009 | 21.565648 | 21.394590 | 21.736707 | +| rows | none | - | 8 | 7.134322 | 6.794458 | 7.658750 | 0.255865 | 7.004837 | 7.263808 | 488.243440 | 479.511935 | 496.974945 | +| columns | none | - | 8 | 8.667350 | 8.351375 | 9.046125 | 0.200737 | 8.565763 | 8.768937 | 401.610950 | 396.923880 | 406.298021 | +| grid | grid | 2x2 | 8 | 9.620889 | 9.494167 | 9.906792 | 0.156201 | 9.541840 | 9.699938 | 361.714077 | 358.787946 | 364.640209 | +| grid | grid | 4x4 | 8 | 7.520278 | 7.180750 | 9.192291 | 0.509240 | 7.262567 | 7.777989 | 464.355097 | 450.562296 | 478.147898 | +| grid | tile | 32x32 | 8 | 7.149239 | 7.000417 | 7.481750 | 0.146932 | 7.074881 | 7.223597 | 486.835720 | 481.901353 | 491.770087 | +| grid | tile | 64x64 | 8 | 7.139611 | 6.827333 | 8.497417 | 0.410477 | 6.931881 | 7.347341 | 488.626677 | 476.135460 | 501.117894 | +| grid | tile | 128x128 | 8 | 7.398150 | 7.072500 | 7.968417 | 0.307492 | 7.242538 | 7.553762 | 471.015450 | 461.354857 | 480.676043 | + +![gaussian3-throughput.svg](gaussian3-throughput.svg) +На этом графике показана зависимость пропускной способности свёртки от числа потоков и стратегии. Видно, что стратегия pixels сильно проигрывает по скорости остальным стратегиям. Это происходит из-за того, что потоки постоянно обращаются к одной и той же атомарной переменной, отвечающей за индекс пикселя. Атомарная операция `getAndIncrement()` дороже обычного инкремента, потому что требует синхронизации со всеми потоками. Все остальные стратегии при увеличении числа потоков завершают свёртку быстрее, чем последовательная, потому что потоки меньше мешают друг другу. Также видно, что при увеличении числа потоков прирост для каждой стратегии становится меньше, что объясняется законом Амдала. Throughput стратегии grid 2x2 перестаёт расти при увеличении числа потоков с 4 до 8, потому что объём работы на каждый поток слишком маленький, из-за чего потоки могут мешать друг другу, как в стратегии pixels. diff --git a/build.gradle.kts b/build.gradle.kts index 677cf93..4c48f61 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,9 @@ plugins { + application kotlin("jvm") version "2.1.10" } -group = "org.example" +group = "convolution" version = "1.0-SNAPSHOT" repositories { @@ -10,12 +11,27 @@ repositories { } dependencies { + implementation("org.apache.commons:commons-csv:1.14.1") + implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.6") + implementation("org.openpnp:opencv:4.9.0-0") testImplementation(kotlin("test")) } tasks.test { useJUnitPlatform() } + +tasks.register("benchmarkConvolution") { + group = "application" + description = "Run the convolution benchmark matrix and save the results to CSV." + classpath = sourceSets["main"].runtimeClasspath + mainClass = "convolution.BenchmarkMainKt" +} + +application { + mainClass = "convolution.MainKt" +} + kotlin { jvmToolchain(23) -} \ No newline at end of file +} diff --git a/gaussian3-throughput.svg b/gaussian3-throughput.svg new file mode 100644 index 0000000..8032356 --- /dev/null +++ b/gaussian3-throughput.svg @@ -0,0 +1,2253 @@ + + + + + + + + 2026-05-28T14:15:02.184583 + image/svg+xml + + + Matplotlib v3.10.9, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/bear.bmp b/images/bear.bmp new file mode 100644 index 0000000..6e88ad4 Binary files /dev/null and b/images/bear.bmp differ diff --git a/images/flowers1.bmp b/images/flowers1.bmp new file mode 100644 index 0000000..a7890cb Binary files /dev/null and b/images/flowers1.bmp differ diff --git a/images/flowers2.bmp b/images/flowers2.bmp new file mode 100644 index 0000000..5d01298 Binary files /dev/null and b/images/flowers2.bmp differ diff --git a/images/forest.bmp b/images/forest.bmp new file mode 100644 index 0000000..a13f7aa Binary files /dev/null and b/images/forest.bmp differ diff --git a/images/fox.bmp b/images/fox.bmp new file mode 100644 index 0000000..564e5b0 Binary files /dev/null and b/images/fox.bmp differ diff --git a/images/wolf.bmp b/images/wolf.bmp new file mode 100644 index 0000000..8afee9b Binary files /dev/null and b/images/wolf.bmp differ diff --git a/scripts/plot_benchmarks.py b/scripts/plot_benchmarks.py new file mode 100644 index 0000000..9aaa917 --- /dev/null +++ b/scripts/plot_benchmarks.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +import csv +import sys +from collections import defaultdict +from pathlib import Path + +import matplotlib +import numpy as np + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +def main() -> int: + if len(sys.argv) != 3: + print("Usage: python3 scripts/plot_benchmarks.py benchmark-results/results.csv plots/") + return 1 + + input_csv = Path(sys.argv[1]) + output_dir = Path(sys.argv[2]) + rows = load_rows(input_csv) + if not rows: + raise SystemExit("CSV file is empty.") + + output_dir.mkdir(parents=True, exist_ok=True) + grouped_rows: dict[str, list[dict[str, object]]] = defaultdict(list) + for row in rows: + grouped_rows[str(row["kernel_set"])].append(row) + + for kernel_set, kernel_rows in grouped_rows.items(): + slug = slugify(kernel_set) + plot_metric( + rows=kernel_rows, + metric_key="avg_ms", + y_label="Average time, ms", + title=f"Convolution performance ({kernel_set})", + output_path=output_dir / f"{slug}-avg-ms.svg", + ) + plot_metric( + rows=kernel_rows, + metric_key="throughput_mp_s", + y_label="Throughput, MP/s", + title=f"Convolution throughput ({kernel_set})", + output_path=output_dir / f"{slug}-throughput.svg", + ) + + print(f"Saved plots to {output_dir}") + return 0 + + +def load_rows(input_csv: Path) -> list[dict[str, object]]: + with input_csv.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + rows: list[dict[str, object]] = [] + for row in reader: + rows.append( + { + "kernel_set": row["kernel_set"], + "mode": row["mode"], + "strategy": row["strategy"], + "partition_kind": row["partition_kind"], + "partition_value": row["partition_value"], + "threads": int(row["threads"]), + "avg_ms": float(row["avg_ms"]), + "min_ms": float(row["min_ms"]), + "max_ms": float(row["max_ms"]), + "stddev_ms": float(row["stddev_ms"]), + "ci95_low_ms": float(row["ci95_low_ms"]), + "ci95_high_ms": float(row["ci95_high_ms"]), + "throughput_mp_s": float(row["throughput_mp_s"]), + "throughput_ci95_low_mp_s": float(row["throughput_ci95_low_mp_s"]), + "throughput_ci95_high_mp_s": float(row["throughput_ci95_high_mp_s"]), + } + ) + return rows + + +def plot_metric( + rows: list[dict[str, object]], + metric_key: str, + y_label: str, + title: str, + output_path: Path, +) -> None: + sequential_rows = [row for row in rows if row["mode"] == "sequential"] + parallel_rows = [row for row in rows if row["mode"] == "parallel"] + + all_threads = sorted({int(row["threads"]) for row in parallel_rows}) + if not all_threads: + all_threads = [1] + + fig, ax = plt.subplots(figsize=(12, 7), constrained_layout=True) + ax.set_title(title, fontsize=18, fontweight="semibold") + ax.set_xlabel("Threads", fontsize=12) + ax.set_ylabel(y_label, fontsize=12) + ax.grid(True, which="major", axis="both", linestyle="--", linewidth=0.7, alpha=0.35) + ax.set_xticks(all_threads) + + if sequential_rows: + baseline_row = sequential_rows[0] + baseline = float(baseline_row[metric_key]) + baseline_low, baseline_high = ci_bounds(baseline_row, metric_key) + ax.axhline( + baseline, + color="black", + linestyle=":", + linewidth=2.0, + label="sequential", + ) + ax.axhspan( + baseline_low, + baseline_high, + color="black", + alpha=0.08, + ) + + series_map: dict[str, list[dict[str, object]]] = defaultdict(list) + for row in parallel_rows: + series_map[series_name(row)].append(row) + + colors = plt.get_cmap("tab10") + for index, name in enumerate(sorted(series_map)): + values = series_map[name] + thread_values = np.array([int(row["threads"]) for row in values], dtype=np.int32) + metric_values = np.array([float(row[metric_key]) for row in values], dtype=np.float64) + error_values = np.array([ci_half_width(row, metric_key) for row in values], dtype=np.float64) + order = np.argsort(thread_values) + thread_values = thread_values[order] + metric_values = metric_values[order] + error_values = error_values[order] + + ax.errorbar( + thread_values, + metric_values, + yerr=error_values, + marker="o", + linewidth=2.2, + markersize=6.0, + label=name, + color=colors(index % 10), + capsize=4.0, + ) + + ax.legend(loc="center left", bbox_to_anchor=(1.01, 0.5), frameon=False) + fig.savefig(output_path, format="svg") + plt.close(fig) + + +def series_name(row: dict[str, object]) -> str: + partition_kind = str(row["partition_kind"]) + partition_value = str(row["partition_value"]) + if partition_kind == "tile": + return f"tile {partition_value}" + if partition_kind == "grid": + return f"grid {partition_value}" + return str(row["strategy"]) + + +def ci_half_width(row: dict[str, object], metric_key: str) -> float: + low, high = ci_bounds(row, metric_key) + return max(0.0, (high - low) / 2.0) + + +def ci_bounds(row: dict[str, object], metric_key: str) -> tuple[float, float]: + if metric_key == "avg_ms": + return float(row["ci95_low_ms"]), float(row["ci95_high_ms"]) + if metric_key == "throughput_mp_s": + return ( + float(row["throughput_ci95_low_mp_s"]), + float(row["throughput_ci95_high_mp_s"]), + ) + raise ValueError(f"Unsupported metric key: {metric_key}") + + +def slugify(value: str) -> str: + allowed: list[str] = [] + for char in value.lower(): + if char.isalnum(): + allowed.append(char) + elif char in {",", ";", "+", " "}: + allowed.append("-") + slug = "".join(allowed).strip("-") + return slug or "benchmark" + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_experiment.sh b/scripts/run_experiment.sh new file mode 100755 index 0000000..a3a4a04 --- /dev/null +++ b/scripts/run_experiment.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +INPUT_PATH="${1:-}" +OUTPUT_CSV="${2:-$PROJECT_ROOT/benchmark-results/benchmark.csv}" + +cd "$PROJECT_ROOT" + +if [[ -z "$INPUT_PATH" ]]; then + echo "Usage: ./scripts/run_experiment.sh [benchmark-results/results.csv]" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$INPUT_PATH")" +mkdir -p "$(dirname "$OUTPUT_CSV")" +mkdir -p "$PROJECT_ROOT/plots" + +./gradlew benchmarkConvolution --args="$INPUT_PATH $OUTPUT_CSV" +python3 scripts/plot_benchmarks.py "$OUTPUT_CSV" "$PROJECT_ROOT/plots" diff --git a/settings.gradle.kts b/settings.gradle.kts index a0d31e5..c4b578a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,5 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } -rootProject.name = "task_1" +rootProject.name = "convolution" diff --git a/src/main/kotlin/convolution/BenchmarkMain.kt b/src/main/kotlin/convolution/BenchmarkMain.kt new file mode 100644 index 0000000..41711a4 --- /dev/null +++ b/src/main/kotlin/convolution/BenchmarkMain.kt @@ -0,0 +1,380 @@ +@file:OptIn(kotlinx.cli.ExperimentalCli::class) + +package convolution + +import java.nio.file.Files +import java.nio.file.Path +import java.util.Locale +import kotlinx.cli.ArgParser +import kotlinx.cli.ArgType +import kotlin.math.sqrt +import kotlin.system.exitProcess +import kotlin.system.measureNanoTime +import org.apache.commons.csv.CSVFormat +import org.apache.commons.csv.CSVPrinter + +private const val WARMUP_ITERATIONS = 5 +private const val MEASURED_ITERATIONS = 15 +private const val NORMAL_Z_95 = 1.96 + +private val BENCHMARK_KERNEL_SETS = listOf( + "gaussian3", + "gaussian3,sharpen3", +) + +private val BENCHMARK_GRID_SIZES = listOf( + 2 to 2, + 4 to 4, +) + +private val BENCHMARK_TILE_SIZES = listOf( + 32 to 32, + 64 to 64, + 128 to 128, +) + +private data class BenchmarkRow( + val kernelSet: String, + val mode: String, + val strategy: String, + val partitionKind: String, + val partitionValue: String, + val threads: Int, + val avgMs: Double, + val minMs: Double, + val maxMs: Double, + val stddevMs: Double, + val ci95LowMs: Double, + val ci95HighMs: Double, + val throughputMpPerSec: Double, + val throughputCi95LowMpPerSec: Double, + val throughputCi95HighMpPerSec: Double, + val imageWidth: Int, + val imageHeight: Int, + val kernelCount: Int, +) + +private data class SampleSummary( + val mean: Double, + val min: Double, + val max: Double, + val stddev: Double, + val ci95Low: Double, + val ci95High: Double, +) + +private object BenchmarkBlackhole { + @Volatile + var value: Int = 0 +} + +fun main(args: Array) { + val (inputPath, outputCsvPath) = parseBenchmarkArgs(args) + + val exitCode = runCatching { + val source = GrayscaleImages.fromMat(OpenCvSupport.readGrayscale(inputPath)) + val rows = runBenchmarks(source) + writeBenchmarkCsv(outputCsvPath, rows) + println("Saved benchmark results to $outputCsvPath") + }.fold( + onSuccess = { 0 }, + onFailure = { error -> + System.err.println(error.message ?: error.toString()) + 1 + }, + ) + + exitProcess(exitCode) +} + +private fun parseBenchmarkArgs(args: Array): Pair { + val parser = ArgParser( + programName = "benchmarkConvolution", + prefixStyle = ArgParser.OptionPrefixStyle.GNU, + ) + val inputPathText by parser.argument(ArgType.String, fullName = "input", description = "Input grayscale image path") + val outputCsvPathText by parser.argument( + ArgType.String, + fullName = "output-csv", + description = "Output benchmark CSV path", + ) + parser.parse(args) + return Path.of(inputPathText) to Path.of(outputCsvPathText) +} + +private fun runBenchmarks(source: GrayscaleImage): List { + val rows = mutableListOf() + val parallelCases = buildParallelCases() + + for (kernelSet in BENCHMARK_KERNEL_SETS) { + val kernels = Kernels.resolveMany(kernelSet) + rows += runBenchmarkCase(source, kernelSet, kernels, parallelOptions = null) + for (options in parallelCases) { + rows += runBenchmarkCase(source, kernelSet, kernels, parallelOptions = options) + } + } + + return rows +} + +private fun buildParallelCases(): List { + val threads = defaultThreadCounts() + return buildList { + for (threadCount in threads) { + add( + ParallelConvolutionOptions( + strategy = PartitionStrategy.PIXELS, + threads = threadCount, + ) + ) + add( + ParallelConvolutionOptions( + strategy = PartitionStrategy.ROWS, + threads = threadCount, + ) + ) + add( + ParallelConvolutionOptions( + strategy = PartitionStrategy.COLUMNS, + threads = threadCount, + ) + ) + for ((gridRows, gridColumns) in BENCHMARK_GRID_SIZES) { + add( + ParallelConvolutionOptions( + strategy = PartitionStrategy.GRID, + threads = threadCount, + gridRows = gridRows, + gridColumns = gridColumns, + ) + ) + } + for ((tileWidth, tileHeight) in BENCHMARK_TILE_SIZES) { + add( + ParallelConvolutionOptions( + strategy = PartitionStrategy.GRID, + threads = threadCount, + tileWidth = tileWidth, + tileHeight = tileHeight, + ) + ) + } + } + } +} + +private fun defaultThreadCounts(): List { + val processors = Runtime.getRuntime().availableProcessors() + return buildList { + add(1) + if (processors >= 2) add(2) + if (processors >= 4) add(4) + if (processors >= 8) add(8) + if (processors !in this) add(processors) + }.distinct() +} + +private fun runBenchmarkCase( + source: GrayscaleImage, + kernelSet: String, + kernels: List, + parallelOptions: ParallelConvolutionOptions?, +): BenchmarkRow { + repeat(WARMUP_ITERATIONS) { + runOnce(source, kernels, parallelOptions) + } + + val timingsMs = DoubleArray(MEASURED_ITERATIONS) + repeat(MEASURED_ITERATIONS) { iteration -> + timingsMs[iteration] = measureNanoTime { + runOnce(source, kernels, parallelOptions) + } / 1_000_000.0 + } + + val row = summarize(kernelSet, kernels, source, parallelOptions, timingsMs) + println( + "Benchmark: kernels=$kernelSet, spec=${displayName(parallelOptions)}, " + + "threads=${row.threads}, avgMs=${formatDouble(row.avgMs)}" + ) + return row +} + +private fun runOnce( + source: GrayscaleImage, + kernels: List, + parallelOptions: ParallelConvolutionOptions?, +) { + val result = if (parallelOptions == null) { + var current = source + for (kernel in kernels) { + current = SequentialConvolution.apply(current, kernel) + } + current + } else { + ParallelConvolution.applyImageToImage( + source = source, + kernels = kernels, + options = parallelOptions, + ) + } + + BenchmarkBlackhole.value = result.pixels[0] +} + +private fun summarize( + kernelSet: String, + kernels: List, + source: GrayscaleImage, + parallelOptions: ParallelConvolutionOptions?, + timingsMs: DoubleArray, +): BenchmarkRow { + val processedMegapixels = source.width.toDouble() * source.height.toDouble() * kernels.size / 1_000_000.0 + val timeStats = summarizeSamples(timingsMs) + val throughputSamples = DoubleArray(timingsMs.size) { index -> + processedMegapixels / (timingsMs[index] / 1_000.0) + } + val throughputStats = summarizeSamples(throughputSamples) + + return BenchmarkRow( + kernelSet = kernelSet, + mode = modeName(parallelOptions), + strategy = strategyName(parallelOptions), + partitionKind = partitionKind(parallelOptions), + partitionValue = partitionValue(parallelOptions), + threads = parallelOptions?.threads ?: 1, + avgMs = timeStats.mean, + minMs = timeStats.min, + maxMs = timeStats.max, + stddevMs = timeStats.stddev, + ci95LowMs = timeStats.ci95Low, + ci95HighMs = timeStats.ci95High, + throughputMpPerSec = throughputStats.mean, + throughputCi95LowMpPerSec = throughputStats.ci95Low, + throughputCi95HighMpPerSec = throughputStats.ci95High, + imageWidth = source.width, + imageHeight = source.height, + kernelCount = kernels.size, + ) +} + +private fun modeName(parallelOptions: ParallelConvolutionOptions?): String { + return if (parallelOptions == null) { + ExecutionMode.SEQUENTIAL.cliName + } else { + ExecutionMode.PARALLEL.cliName + } +} + +private fun strategyName(parallelOptions: ParallelConvolutionOptions?): String { + return parallelOptions?.strategy?.cliName ?: "sequential" +} + +private fun partitionKind(parallelOptions: ParallelConvolutionOptions?): String { + return when { + parallelOptions == null -> "none" + parallelOptions.tileWidth != null && parallelOptions.tileHeight != null -> "tile" + parallelOptions.strategy == PartitionStrategy.GRID -> "grid" + else -> "none" + } +} + +private fun partitionValue(parallelOptions: ParallelConvolutionOptions?): String { + return when (partitionKind(parallelOptions)) { + "tile" -> "${parallelOptions?.tileWidth}x${parallelOptions?.tileHeight}" + "grid" -> "${parallelOptions?.gridRows}x${parallelOptions?.gridColumns}" + else -> "-" + } +} + +private fun displayName(parallelOptions: ParallelConvolutionOptions?): String { + return when (partitionKind(parallelOptions)) { + "none" -> parallelOptions?.strategy?.cliName ?: "sequential" + "tile" -> "tile ${parallelOptions?.tileWidth}x${parallelOptions?.tileHeight}" + "grid" -> "grid ${parallelOptions?.gridRows}x${parallelOptions?.gridColumns}" + else -> error("Unsupported benchmark case.") + } +} + +private fun writeBenchmarkCsv(outputPath: Path, rows: List) { + outputPath.parent?.let(Files::createDirectories) + Files.newBufferedWriter(outputPath).use { writer -> + CSVPrinter(writer, CSVFormat.DEFAULT).use { csv -> + csv.printRecord( + "kernel_set", + "mode", + "strategy", + "partition_kind", + "partition_value", + "threads", + "avg_ms", + "min_ms", + "max_ms", + "stddev_ms", + "ci95_low_ms", + "ci95_high_ms", + "throughput_mp_s", + "throughput_ci95_low_mp_s", + "throughput_ci95_high_mp_s", + "image_width", + "image_height", + "kernel_count", + ) + + for (row in rows) { + csv.printRecord( + row.kernelSet, + row.mode, + row.strategy, + row.partitionKind, + row.partitionValue, + row.threads, + formatDouble(row.avgMs), + formatDouble(row.minMs), + formatDouble(row.maxMs), + formatDouble(row.stddevMs), + formatDouble(row.ci95LowMs), + formatDouble(row.ci95HighMs), + formatDouble(row.throughputMpPerSec), + formatDouble(row.throughputCi95LowMpPerSec), + formatDouble(row.throughputCi95HighMpPerSec), + row.imageWidth, + row.imageHeight, + row.kernelCount, + ) + } + } + } +} + +private fun formatDouble(value: Double): String = String.format(Locale.US, "%.6f", value) + +private fun summarizeSamples(samples: DoubleArray): SampleSummary { + require(samples.isNotEmpty()) { + "At least one measurement is required." + } + + val mean = samples.average() + val min = samples.minOrNull() ?: mean + val max = samples.maxOrNull() ?: mean + val stddev = if (samples.size > 1) { + val variance = samples.sumOf { delta -> (delta - mean) * (delta - mean) } / (samples.size - 1) + sqrt(variance) + } else { + 0.0 + } + val margin = if (samples.size > 1) { + val sem = stddev / sqrt(samples.size.toDouble()) + NORMAL_Z_95 * sem + } else { + 0.0 + } + + return SampleSummary( + mean = mean, + min = min, + max = max, + stddev = stddev, + ci95Low = mean - margin, + ci95High = mean + margin, + ) +} diff --git a/src/main/kotlin/convolution/ConvolutionKernel.kt b/src/main/kotlin/convolution/ConvolutionKernel.kt new file mode 100644 index 0000000..7abdfc9 --- /dev/null +++ b/src/main/kotlin/convolution/ConvolutionKernel.kt @@ -0,0 +1,76 @@ +package convolution + +data class ConvolutionKernel( + val name: String, + val size: Int, + val coefficients: DoubleArray, +) { + init { + require(size % 2 == 1) { "Kernel size must be odd." } + require(coefficients.size == size * size) { + "Kernel $name must contain exactly ${size * size} coefficients." + } + } +} + +object Kernels { + private val all = listOf( + ConvolutionKernel( + name = "identity3", + size = 3, + coefficients = doubleArrayOf( + 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, + ), + ), + ConvolutionKernel( + name = "box3", + size = 3, + coefficients = doubleArrayOf( + 1.0 / 9.0, 1.0 / 9.0, 1.0 / 9.0, + 1.0 / 9.0, 1.0 / 9.0, 1.0 / 9.0, + 1.0 / 9.0, 1.0 / 9.0, 1.0 / 9.0, + ), + ), + ConvolutionKernel( + name = "gaussian3", + size = 3, + coefficients = doubleArrayOf( + 1.0 / 16.0, 2.0 / 16.0, 1.0 / 16.0, + 2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0, + 1.0 / 16.0, 2.0 / 16.0, 1.0 / 16.0, + ), + ), + ConvolutionKernel( + name = "sharpen3", + size = 3, + coefficients = doubleArrayOf( + 0.0, -1.0, 0.0, + -1.0, 5.0, -1.0, + 0.0, -1.0, 0.0, + ), + ), + ) + + private val byName = all.associateBy { it.name } + + fun resolve(name: String): ConvolutionKernel { + return byName[name] ?: error( + "Unknown kernel '$name'. Available kernels: ${availableNames()}." + ) + } + + fun resolveMany(names: String): List { + val resolved = names + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .map(::resolve) + + require(resolved.isNotEmpty()) { "At least one kernel must be specified." } + return resolved + } + + fun availableNames(): String = all.joinToString(", ") { it.name } +} diff --git a/src/main/kotlin/convolution/ConvolutionWorker.kt b/src/main/kotlin/convolution/ConvolutionWorker.kt new file mode 100644 index 0000000..00d5b7c --- /dev/null +++ b/src/main/kotlin/convolution/ConvolutionWorker.kt @@ -0,0 +1,57 @@ +package convolution + +import kotlin.math.roundToInt + +internal object ConvolutionWorker { + fun validateInput(width: Int, height: Int, input: IntArray) { + require(width > 0) { "Image width must be positive." } + require(height > 0) { "Image height must be positive." } + require(input.size == width * height) { + "Input array size ${input.size} does not match image shape ${width}x$height." + } + } + + fun convolveRegion( + width: Int, + height: Int, + input: IntArray, + output: IntArray, + kernel: ConvolutionKernel, + xStart: Int, + xEnd: Int, + yStart: Int, + yEnd: Int, + ) { + for (y in yStart until yEnd) { + for (x in xStart until xEnd) { + output[y * width + x] = convolvePixel(width, height, input, kernel, x, y) + } + } + } + + fun convolvePixel( + width: Int, + height: Int, + input: IntArray, + kernel: ConvolutionKernel, + x: Int, + y: Int, + ): Int { + val radius = kernel.size / 2 + var sum = 0.0 + var kernelIndex = 0 + + for (kernelY in -radius..radius) { + val sourceY = (y + kernelY).coerceIn(0, height - 1) + val rowOffset = sourceY * width + + for (kernelX in -radius..radius) { + val sourceX = (x + kernelX).coerceIn(0, width - 1) + val pixel = input[rowOffset + sourceX] + sum += pixel * kernel.coefficients[kernelIndex++] + } + } + + return sum.roundToInt().coerceIn(0, 255) + } +} diff --git a/src/main/kotlin/convolution/ExecutionMode.kt b/src/main/kotlin/convolution/ExecutionMode.kt new file mode 100644 index 0000000..9d7d25f --- /dev/null +++ b/src/main/kotlin/convolution/ExecutionMode.kt @@ -0,0 +1,15 @@ +package convolution + +enum class ExecutionMode(val cliName: String) { + SEQUENTIAL("sequential"), + PARALLEL("parallel"); + + companion object { + fun resolve(name: String): ExecutionMode { + return entries.firstOrNull { it.cliName == name } + ?: error("Unknown mode '$name'. Available modes: ${availableNames()}.") + } + + fun availableNames(): String = entries.joinToString(", ") { it.cliName } + } +} diff --git a/src/main/kotlin/convolution/GrayscaleImage.kt b/src/main/kotlin/convolution/GrayscaleImage.kt new file mode 100644 index 0000000..9ba722c --- /dev/null +++ b/src/main/kotlin/convolution/GrayscaleImage.kt @@ -0,0 +1,43 @@ +package convolution + +import org.opencv.core.CvType +import org.opencv.core.Mat + +data class GrayscaleImage( + val width: Int, + val height: Int, + val pixels: IntArray, +) { + init { + ConvolutionWorker.validateInput(width, height, pixels) + } +} + +object GrayscaleImages { + fun fromMat(source: Mat): GrayscaleImage { + require(source.type() == CvType.CV_8UC1) { + "Expected a grayscale CV_8UC1 image, got type ${source.type()}." + } + + val width = source.cols() + val height = source.rows() + val inputBytes = ByteArray(width * height) + source.get(0, 0, inputBytes) + + val input = IntArray(inputBytes.size) { index -> + inputBytes[index].toInt() and 0xFF + } + + return GrayscaleImage(width, height, input) + } + + fun toMat(image: GrayscaleImage): Mat { + val outputBytes = ByteArray(image.pixels.size) { index -> + image.pixels[index].toByte() + } + + return Mat(image.height, image.width, CvType.CV_8UC1).apply { + put(0, 0, outputBytes) + } + } +} diff --git a/src/main/kotlin/convolution/KernelComposition.kt b/src/main/kotlin/convolution/KernelComposition.kt new file mode 100644 index 0000000..8ccbdf9 --- /dev/null +++ b/src/main/kotlin/convolution/KernelComposition.kt @@ -0,0 +1,49 @@ +package convolution + +object KernelComposition { + fun prepare(kernels: List, compose: Boolean): List { + require(kernels.isNotEmpty()) { "At least one kernel must be specified." } + return if (compose && kernels.size > 1) listOf(composeAll(kernels)) else kernels + } + + fun composeAll(kernels: List): ConvolutionKernel { + require(kernels.isNotEmpty()) { "At least one kernel must be specified." } + val composed = kernels.reduce(::compose) + return composed.copy(name = "compose(${kernels.joinToString(",") { it.name }})") + } + + fun compose(first: ConvolutionKernel, second: ConvolutionKernel): ConvolutionKernel { + val size = first.size + second.size - 1 + val radius = size / 2 + val firstRadius = first.size / 2 + val secondRadius = second.size / 2 + val coefficients = DoubleArray(size * size) + + for (firstY in 0 until first.size) { + val firstOffsetY = firstY - firstRadius + for (firstX in 0 until first.size) { + val firstCoefficient = first.coefficients[firstY * first.size + firstX] + if (firstCoefficient == 0.0) { + continue + } + + val firstOffsetX = firstX - firstRadius + for (secondY in 0 until second.size) { + val combinedY = firstOffsetY + (secondY - secondRadius) + radius + for (secondX in 0 until second.size) { + val combinedX = firstOffsetX + (secondX - secondRadius) + radius + val combinedIndex = combinedY * size + combinedX + coefficients[combinedIndex] += + firstCoefficient * second.coefficients[secondY * second.size + secondX] + } + } + } + } + + return ConvolutionKernel( + name = "compose(${first.name},${second.name})", + size = size, + coefficients = coefficients, + ) + } +} diff --git a/src/main/kotlin/convolution/Main.kt b/src/main/kotlin/convolution/Main.kt new file mode 100644 index 0000000..abcea24 --- /dev/null +++ b/src/main/kotlin/convolution/Main.kt @@ -0,0 +1,165 @@ +@file:OptIn(kotlinx.cli.ExperimentalCli::class) + +package convolution + +import java.nio.file.Path +import kotlinx.cli.ArgParser +import kotlinx.cli.ArgType +import kotlinx.cli.default +import kotlinx.cli.optional +import kotlin.system.exitProcess + +private data class CliConfig( + val inputPath: Path, + val outputPath: Path, + val kernelNames: String, + val composeKernels: Boolean, + val mode: ExecutionMode, + val parallelOptions: ParallelConvolutionOptions, +) + +fun main(args: Array) { + val config = parseArgs(args) + + val exitCode = runCatching { + val requestedKernels = Kernels.resolveMany(config.kernelNames) + val effectiveKernels = KernelComposition.prepare(requestedKernels, config.composeKernels) + val source = OpenCvSupport.readGrayscale(config.inputPath) + val result = when (config.mode) { + ExecutionMode.SEQUENTIAL -> applySequential(source = source, kernels = effectiveKernels) + ExecutionMode.PARALLEL -> ParallelConvolution.applyMatToMat( + source = source, + kernels = effectiveKernels, + options = config.parallelOptions, + ) + } + + OpenCvSupport.write(config.outputPath, result) + println("Requested kernels: ${requestedKernels.joinToString(",") { it.name }}") + if (config.composeKernels && requestedKernels.size > 1) { + val composedKernel = effectiveKernels.single() + println("Applied as composed kernel: ${composedKernel.name} (${composedKernel.size}x${composedKernel.size})") + println("Note: composed-kernel output can differ from the multi-pass pipeline because each regular pass rounds pixels and reapplies clamp borders.") + } else { + println("Applied kernels: ${effectiveKernels.joinToString(",") { it.name }}") + } + println("Saved result to ${config.outputPath}") + println("Mode: ${config.mode.cliName}") + if (config.mode == ExecutionMode.PARALLEL) { + println( + "Parallel strategy: ${config.parallelOptions.strategy.cliName}, " + + "threads: ${config.parallelOptions.threads}, " + + gridDescription(config.parallelOptions) + ) + } + }.fold( + onSuccess = { 0 }, + onFailure = { error -> + System.err.println(error.message ?: error.toString()) + 1 + }, + ) + + exitProcess(exitCode) +} + +private fun parseArgs(args: Array): CliConfig { + val parser = ArgParser( + programName = "convolution", + prefixStyle = ArgParser.OptionPrefixStyle.GNU, + ) + val inputPathText by parser.argument(ArgType.String, fullName = "input", description = "Input grayscale image path") + val outputPathText by parser.argument(ArgType.String, fullName = "output", description = "Output image path") + val kernelNamesText by parser.argument( + ArgType.String, + fullName = "kernels", + description = "Comma-separated kernel list", + ).optional() + val composeKernels by parser.option( + type = ArgType.Boolean, + fullName = "compose-kernels", + description = "Compose several kernels into one larger matrix before execution", + ).default(false) + val modeName by parser.option( + type = ArgType.Choice(ExecutionMode.entries.map { it.cliName }, { it }), + fullName = "mode", + description = "Execution mode", + ).default(ExecutionMode.PARALLEL.cliName) + val strategyName by parser.option( + type = ArgType.Choice(PartitionStrategy.entries.map { it.cliName }, { it }), + fullName = "strategy", + description = "Parallel partition strategy", + ).default(PartitionStrategy.ROWS.cliName) + val threads by parser.option( + type = ArgType.Int, + fullName = "threads", + description = "Thread count", + ).default(Runtime.getRuntime().availableProcessors()) + val gridText by parser.option( + type = ArgType.String, + fullName = "grid", + description = "Grid partition in ROWSxCOLUMNS form", + ).default("2x2") + val tileText by parser.option( + type = ArgType.String, + fullName = "tile", + description = "Tile size in WIDTHxHEIGHT form", + ) + + parser.parse(args) + + val grid = parsePair(gridText, "grid") + val tile = tileText?.let { parsePair(it, "tile") } + + return CliConfig( + inputPath = Path.of(inputPathText), + outputPath = Path.of(outputPathText), + kernelNames = kernelNamesText ?: "gaussian3", + composeKernels = composeKernels, + mode = ExecutionMode.resolve(modeName), + parallelOptions = ParallelConvolutionOptions( + strategy = PartitionStrategy.resolve(strategyName), + threads = threads, + gridRows = grid.first, + gridColumns = grid.second, + tileWidth = tile?.first, + tileHeight = tile?.second, + ), + ) +} + +private fun applySequential(source: org.opencv.core.Mat, kernels: List): org.opencv.core.Mat { + var current = GrayscaleImages.fromMat(source) + for (kernel in kernels) { + current = SequentialConvolution.apply(current, kernel) + } + return GrayscaleImages.toMat(current) +} + +private fun parsePair(value: String, optionName: String): Pair { + val parts = value.lowercase().split("x") + require(parts.size == 2) { + "Option --$optionName must use AxB syntax, got '$value'." + } + + val first = parts[0].toIntOrNull() + val second = parts[1].toIntOrNull() + require(first != null && second != null) { + "Option --$optionName must contain integer values, got '$value'." + } + require(first > 0 && second > 0) { + "Option --$optionName must contain positive integers, got '$value'." + } + + return Pair(first, second) +} + +private fun gridDescription(options: ParallelConvolutionOptions): String { + val tileWidth = options.tileWidth + val tileHeight = options.tileHeight + return if (tileWidth != null && tileHeight != null) { + "tile: ${tileWidth}x$tileHeight" + } else { + "grid: ${options.gridRows}x${options.gridColumns}" + } +} diff --git a/src/main/kotlin/convolution/OpenCvSupport.kt b/src/main/kotlin/convolution/OpenCvSupport.kt new file mode 100644 index 0000000..f09993f --- /dev/null +++ b/src/main/kotlin/convolution/OpenCvSupport.kt @@ -0,0 +1,38 @@ +package convolution + +import nu.pattern.OpenCV +import org.opencv.core.Core +import org.opencv.core.Mat +import org.opencv.imgcodecs.Imgcodecs +import java.nio.file.Files +import java.nio.file.Path + +object OpenCvSupport { + private var loaded = false + + fun readGrayscale(path: Path): Mat { + path.parent?.let(Files::createDirectories) + require(Files.exists(path)) { "Input file does not exist: $path" } + ensureLoaded() + + val image = Imgcodecs.imread(path.toString(), Imgcodecs.IMREAD_GRAYSCALE) + require(!image.empty()) { "Failed to read image from $path" } + return image + } + + fun write(path: Path, image: Mat) { + ensureLoaded() + path.parent?.let(Files::createDirectories) + check(Imgcodecs.imwrite(path.toString(), image)) { "Failed to write image to $path" } + } + + private fun ensureLoaded() { + if (loaded) { + return + } + + OpenCV.loadLocally() + Core.setNumThreads(1) + loaded = true + } +} diff --git a/src/main/kotlin/convolution/ParallelConvolution.kt b/src/main/kotlin/convolution/ParallelConvolution.kt new file mode 100644 index 0000000..b6410c8 --- /dev/null +++ b/src/main/kotlin/convolution/ParallelConvolution.kt @@ -0,0 +1,236 @@ +package convolution + +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import org.opencv.core.Mat + +enum class PartitionStrategy(val cliName: String) { + PIXELS("pixels"), + ROWS("rows"), + COLUMNS("columns"), + GRID("grid"); + + companion object { + fun resolve(name: String): PartitionStrategy { + return entries.firstOrNull { it.cliName == name } + ?: error("Unknown strategy '$name'. Available strategies: ${availableNames()}.") + } + + fun availableNames(): String = entries.joinToString(", ") { it.cliName } + } +} + +data class ParallelConvolutionOptions( + val strategy: PartitionStrategy = PartitionStrategy.ROWS, + val threads: Int = Runtime.getRuntime().availableProcessors(), + val gridRows: Int = 2, + val gridColumns: Int = 2, + val tileWidth: Int? = null, + val tileHeight: Int? = null, +) { + init { + require(threads > 0) { "Thread count must be positive." } + require(gridRows > 0) { "Grid row count must be positive." } + require(gridColumns > 0) { "Grid column count must be positive." } + require((tileWidth == null) == (tileHeight == null)) { + "Tile width and tile height must be specified together." + } + tileWidth?.let { require(it > 0) { "Tile width must be positive." } } + tileHeight?.let { require(it > 0) { "Tile height must be positive." } } + } +} + +object ParallelConvolution { + fun applyMatToMat( + source: Mat, + kernels: List, + options: ParallelConvolutionOptions, + ): Mat { + return GrayscaleImages.toMat(applyImageToImage(GrayscaleImages.fromMat(source), kernels, options)) + } + + fun applyImageToImage( + source: GrayscaleImage, + kernels: List, + options: ParallelConvolutionOptions, + ): GrayscaleImage { + require(kernels.isNotEmpty()) { "At least one kernel must be specified." } + + var current = source.pixels + for (kernel in kernels) { + current = apply(source.width, source.height, current, kernel, options) + } + + return source.copy(pixels = current) + } + + fun apply( + width: Int, + height: Int, + input: IntArray, + kernel: ConvolutionKernel, + options: ParallelConvolutionOptions, + ): IntArray { + ConvolutionWorker.validateInput(width, height, input) + + val output = IntArray(input.size) + when (options.strategy) { + PartitionStrategy.PIXELS -> convolveByPixels(width, height, input, output, kernel, options.threads) + PartitionStrategy.ROWS -> convolveByRows(width, height, input, output, kernel, options.threads) + PartitionStrategy.COLUMNS -> convolveByColumns(width, height, input, output, kernel, options.threads) + PartitionStrategy.GRID -> convolveByGrid(width, height, input, output, kernel, options) + } + + return output + } + + private fun convolveByPixels( + width: Int, + height: Int, + input: IntArray, + output: IntArray, + kernel: ConvolutionKernel, + threads: Int, + ) { + val nextPixel = AtomicInteger(0) + runWorkers(threads) { + var index = nextPixel.getAndIncrement() + while (index < input.size) { + val y = index / width + val x = index - y * width + output[index] = ConvolutionWorker.convolvePixel(width, height, input, kernel, x, y) + index = nextPixel.getAndIncrement() + } + } + } + + private fun convolveByRows( + width: Int, + height: Int, + input: IntArray, + output: IntArray, + kernel: ConvolutionKernel, + threads: Int, + ) { + val nextRow = AtomicInteger(0) + runWorkers(threads) { + var y = nextRow.getAndIncrement() + while (y < height) { + ConvolutionWorker.convolveRegion(width, height, input, output, kernel, 0, width, y, y + 1) + y = nextRow.getAndIncrement() + } + + } + } + + private fun convolveByColumns( + width: Int, + height: Int, + input: IntArray, + output: IntArray, + kernel: ConvolutionKernel, + threads: Int, + ) { + val nextColumn = AtomicInteger(0) + runWorkers(threads) { + var x = nextColumn.getAndIncrement() + while (x < width) { + ConvolutionWorker.convolveRegion(width, height, input, output, kernel, x, x + 1, 0, height) + x = nextColumn.getAndIncrement() + } + } + } + + private fun convolveByGrid( + width: Int, + height: Int, + input: IntArray, + output: IntArray, + kernel: ConvolutionKernel, + options: ParallelConvolutionOptions, + ) { + val regions = buildGridRegions(width, height, options) + val nextRegion = AtomicInteger(0) + + runWorkers(options.threads) { + var index = nextRegion.getAndIncrement() + while (index < regions.size) { + val region = regions[index] + ConvolutionWorker.convolveRegion( + width = width, + height = height, + input = input, + output = output, + kernel = kernel, + xStart = region.xStart, + xEnd = region.xEnd, + yStart = region.yStart, + yEnd = region.yEnd, + ) + index = nextRegion.getAndIncrement() + } + } + } + + private fun runWorkers(threads: Int, work: () -> Unit) { + val executor = Executors.newFixedThreadPool(threads) + try { + val futures = List(threads) { + executor.submit { work() } + } + for (future in futures) { + future.get() + } + } finally { + executor.shutdown() + } + } + + private fun buildGridRegions( + width: Int, + height: Int, + options: ParallelConvolutionOptions, + ): List { + val tileWidth = options.tileWidth + val tileHeight = options.tileHeight + + if (tileWidth != null && tileHeight != null) { + return buildList { + var y = 0 + while (y < height) { + val yEnd = (y + tileHeight).coerceAtMost(height) + var x = 0 + while (x < width) { + val xEnd = (x + tileWidth).coerceAtMost(width) + add(Region(x, xEnd, y, yEnd)) + x += tileWidth + } + y += tileHeight + } + } + } + + return buildList { + for (gridY in 0 until options.gridRows) { + val yStart = gridY * height / options.gridRows + val yEnd = (gridY + 1) * height / options.gridRows + + for (gridX in 0 until options.gridColumns) { + val xStart = gridX * width / options.gridColumns + val xEnd = (gridX + 1) * width / options.gridColumns + + if (xStart < xEnd && yStart < yEnd) { + add(Region(xStart, xEnd, yStart, yEnd)) + } + } + } + } + } + + private data class Region( + val xStart: Int, + val xEnd: Int, + val yStart: Int, + val yEnd: Int, + ) +} diff --git a/src/main/kotlin/convolution/SequentialConvolution.kt b/src/main/kotlin/convolution/SequentialConvolution.kt new file mode 100644 index 0000000..d435dcc --- /dev/null +++ b/src/main/kotlin/convolution/SequentialConvolution.kt @@ -0,0 +1,28 @@ +package convolution + +import org.opencv.core.Mat + +object SequentialConvolution { + fun apply(source: GrayscaleImage, kernel: ConvolutionKernel): GrayscaleImage { + val output = applyAux(source.width, source.height, source.pixels, kernel) + return source.copy(pixels = output) + } + + fun applyAux(width: Int, height: Int, input: IntArray, kernel: ConvolutionKernel): IntArray { + ConvolutionWorker.validateInput(width, height, input) + val output = IntArray(input.size) + + ConvolutionWorker.convolveRegion( + width = width, + height = height, + input = input, + output = output, + kernel = kernel, + xStart = 0, + xEnd = width, + yStart = 0, + yEnd = height, + ) + return output + } +} diff --git a/src/test/kotlin/convolution/KernelCompositionTest.kt b/src/test/kotlin/convolution/KernelCompositionTest.kt new file mode 100644 index 0000000..2f148c5 --- /dev/null +++ b/src/test/kotlin/convolution/KernelCompositionTest.kt @@ -0,0 +1,108 @@ +package convolution + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KernelCompositionTest { + @Test + fun `box blur composed with box blur produces expected 5x5 kernel`() { + val composed = KernelComposition.composeAll(Kernels.resolveMany("box3,box3")) + + assertEquals(5, composed.size) + assertDoubleArrayClose( + expected = doubleArrayOf( + 1.0 / 81.0, 2.0 / 81.0, 3.0 / 81.0, 2.0 / 81.0, 1.0 / 81.0, + 2.0 / 81.0, 4.0 / 81.0, 6.0 / 81.0, 4.0 / 81.0, 2.0 / 81.0, + 3.0 / 81.0, 6.0 / 81.0, 9.0 / 81.0, 6.0 / 81.0, 3.0 / 81.0, + 2.0 / 81.0, 4.0 / 81.0, 6.0 / 81.0, 4.0 / 81.0, 2.0 / 81.0, + 1.0 / 81.0, 2.0 / 81.0, 3.0 / 81.0, 2.0 / 81.0, 1.0 / 81.0, + ), + actual = composed.coefficients, + ) + } + + @Test + fun `composed kernel matches unrounded sequential pipeline away from borders`() { + val input = DoubleArray(81) { index -> ((index * 17 + 13) % 256).toDouble() } + val kernels = Kernels.resolveMany("gaussian3,sharpen3") + + val afterFirst = convolveToDouble(9, 9, input, kernels[0]) + val expected = convolveToDouble(9, 9, afterFirst, kernels[1]) + val actual = convolveToDouble(9, 9, input, KernelComposition.composeAll(kernels)) + + assertInteriorEquals(width = 9, height = 9, radius = 2, expected = expected, actual = actual) + } + + @Test + fun `composed kernel matches sequential application on constant image`() { + val input = IntArray(49) { 42 } + val kernels = Kernels.resolveMany("gaussian3,sharpen3") + + val afterFirst = SequentialConvolution.applyAux(7, 7, input, kernels[0]) + val expected = SequentialConvolution.applyAux(7, 7, afterFirst, kernels[1]) + val actual = SequentialConvolution.applyAux(7, 7, input, KernelComposition.composeAll(kernels)) + + assertContentEquals(expected, actual) + } + + private fun assertInteriorEquals( + width: Int, + height: Int, + radius: Int, + expected: DoubleArray, + actual: DoubleArray, + ) { + for (y in radius until height - radius) { + for (x in radius until width - radius) { + val index = y * width + x + val delta = abs(expected[index] - actual[index]) + assertTrue(delta <= 1e-12, "Mismatch at ($x, $y): expected=${expected[index]}, actual=${actual[index]}") + } + } + } + + private fun convolveToDouble( + width: Int, + height: Int, + input: DoubleArray, + kernel: ConvolutionKernel, + ): DoubleArray { + val output = DoubleArray(input.size) + val radius = kernel.size / 2 + + for (y in 0 until height) { + for (x in 0 until width) { + var sum = 0.0 + var kernelIndex = 0 + + for (kernelY in -radius..radius) { + val sourceY = (y + kernelY).coerceIn(0, height - 1) + val rowOffset = sourceY * width + for (kernelX in -radius..radius) { + val sourceX = (x + kernelX).coerceIn(0, width - 1) + sum += input[rowOffset + sourceX] * kernel.coefficients[kernelIndex++] + } + } + + output[y * width + x] = sum + } + } + + return output + } + + private fun assertDoubleArrayClose( + expected: DoubleArray, + actual: DoubleArray, + epsilon: Double = 1e-12, + ) { + assertEquals(expected.size, actual.size, "Different kernel coefficient count.") + for (index in expected.indices) { + val delta = abs(expected[index] - actual[index]) + assertTrue(delta <= epsilon, "Mismatch at coefficient $index: expected=${expected[index]}, actual=${actual[index]}") + } + } +} diff --git a/src/test/kotlin/convolution/ParallelConvolutionTest.kt b/src/test/kotlin/convolution/ParallelConvolutionTest.kt new file mode 100644 index 0000000..914eaa9 --- /dev/null +++ b/src/test/kotlin/convolution/ParallelConvolutionTest.kt @@ -0,0 +1,83 @@ +package convolution + +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class ParallelConvolutionTest { + private val input = intArrayOf( + 10, 20, 30, 40, 50, + 60, 70, 80, 90, 100, + 110, 120, 130, 140, 150, + 160, 170, 180, 190, 200, + ) + + @Test + fun `all partition strategies match sequential convolution`() { + val expected = SequentialConvolution.applyAux( + width = 5, + height = 4, + input = input, + kernel = Kernels.resolve("sharpen3"), + ) + + for (strategy in PartitionStrategy.entries) { + val actual = ParallelConvolution.apply( + width = 5, + height = 4, + input = input, + kernel = Kernels.resolve("sharpen3"), + options = ParallelConvolutionOptions( + strategy = strategy, + threads = 3, + gridRows = 2, + gridColumns = 3, + ), + ) + + assertContentEquals(expected, actual, "Strategy ${strategy.cliName} differs from sequential result.") + } + } + + @Test + fun `grid tile size matches sequential convolution`() { + val expected = SequentialConvolution.applyAux( + width = 5, + height = 4, + input = input, + kernel = Kernels.resolve("box3"), + ) + + val actual = ParallelConvolution.apply( + width = 5, + height = 4, + input = input, + kernel = Kernels.resolve("box3"), + options = ParallelConvolutionOptions( + strategy = PartitionStrategy.GRID, + threads = 4, + tileWidth = 2, + tileHeight = 3, + ), + ) + + assertContentEquals(expected, actual) + } + + @Test + fun `multiple kernels are applied sequentially inside parallel pipeline`() { + val kernels = Kernels.resolveMany("gaussian3,sharpen3") + val expectedAfterGaussian = SequentialConvolution.applyAux(5, 4, input, kernels[0]) + val expected = SequentialConvolution.applyAux(5, 4, expectedAfterGaussian, kernels[1]) + + val actual = ParallelConvolution.applyImageToImage( + source = GrayscaleImage(width = 5, height = 4, pixels = input), + kernels = kernels, + options = ParallelConvolutionOptions( + strategy = PartitionStrategy.ROWS, + threads = 2, + ), + ) + + assertContentEquals(expected, actual.pixels) + } +} diff --git a/src/test/kotlin/convolution/SequentialConvolutionTest.kt b/src/test/kotlin/convolution/SequentialConvolutionTest.kt new file mode 100644 index 0000000..d713456 --- /dev/null +++ b/src/test/kotlin/convolution/SequentialConvolutionTest.kt @@ -0,0 +1,48 @@ +package convolution + +import kotlin.test.Test +import kotlin.test.assertContentEquals + +class SequentialConvolutionTest { + @Test + fun `identity kernel keeps the image unchanged`() { + val input = intArrayOf( + 10, 20, 30, + 40, 50, 60, + 70, 80, 90, + ) + + val actual = SequentialConvolution.applyAux( + width = 3, + height = 3, + input = input, + kernel = Kernels.resolve("identity3"), + ) + + assertContentEquals(input, actual) + } + + @Test + fun `box blur uses replicated borders`() { + val input = intArrayOf( + 1, 2, 3, + 4, 5, 6, + 7, 8, 9, + ) + + val actual = SequentialConvolution.applyAux( + width = 3, + height = 3, + input = input, + kernel = Kernels.resolve("box3"), + ) + + val expected = intArrayOf( + 2, 3, 4, + 4, 5, 6, + 6, 7, 8, + ) + + assertContentEquals(expected, actual) + } +}