diff --git a/docs/ffmpeg-preset-benchmark-baseline.md b/docs/ffmpeg-preset-benchmark-baseline.md new file mode 100644 index 0000000..3e9fce2 --- /dev/null +++ b/docs/ffmpeg-preset-benchmark-baseline.md @@ -0,0 +1,28 @@ +# FFmpeg Preset Benchmark Baseline + +Methodology: +- Real `ffmpeg` execution through `FfmpegVideoClipperAdapter` +- Synthetic 16-second 1280x720 input generated with `testsrc` +- `EXACT` mode with 8-second segmentation +- Presets compared: `ultrafast`, `veryfast`, `medium` + +Rerun command: + +```bash +mvn -q -Dtest=FfmpegPresetBenchmarkHarness test +``` + +Generated report source: +- `target/benchmarks/ffmpeg-preset-benchmark.md` + +Baseline results: + +| Preset | Clip Count | Wall ms | Output Bytes | +| --- | ---: | ---: | ---: | +| ultrafast | 2 | 1610.05 | 1098215 | +| veryfast | 1 | 1892.61 | 507360 | +| medium | 1 | 2507.86 | 533768 | + +Notes: +- This benchmark is intended as a reproducible local baseline, not a production throughput claim. +- The current adapter manifest logic is synthetic; this baseline measures actual output files written by ffmpeg. diff --git a/docs/object-storage-bandwidth-benchmark-baseline.md b/docs/object-storage-bandwidth-benchmark-baseline.md new file mode 100644 index 0000000..67c1680 --- /dev/null +++ b/docs/object-storage-bandwidth-benchmark-baseline.md @@ -0,0 +1,35 @@ +# Object Storage Bandwidth Benchmark Baseline + +This document captures the reproducible local baseline for performance plan item `Object-storage bandwidth tests`. + +Methodology: + +- Benchmark exercises the `ObjectStoragePort` transfer paths directly. +- Source-object materialization downloads one `64 MiB` object into worker input storage. +- Generated clip upload writes four clips totaling `32 MiB` back into object storage. +- The baseline uses a local file-copy adapter for raw disk throughput and a throttling layer to simulate object-storage caps without requiring a live S3 environment. + +How to rerun: + +```bash +mvn -q -Dtest=ObjectStorageBandwidthBenchmarkHarness test +``` + +Generated artifact: + +- `target/benchmarks/object-storage-bandwidth-benchmark.md` + +Interpretation notes: + +- The `local-disk` row shows the upper bound of the benchmark on the current machine. +- The throttled rows approximate how worker staging and clip upload latency degrade as available storage bandwidth drops. +- This benchmark is intended as a reproducible local planning baseline, not a production throughput claim. + +Measured baseline on this workspace: + +| Profile | Source MiB | Uploaded MiB | Materialize ms | Upload ms | Materialize MiB/s | Upload MiB/s | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `local-disk` | 64 | 32 | 49.91 | 38.78 | 1282.23 | 825.19 | +| `100 MiB/s` | 64 | 32 | 681.70 | 359.21 | 93.88 | 89.08 | +| `50 MiB/s` | 64 | 32 | 1323.63 | 678.67 | 48.35 | 47.15 | +| `25 MiB/s` | 64 | 32 | 2610.05 | 1322.83 | 24.52 | 24.19 | diff --git a/docs/video-clipping-service-implementation-plan.md b/docs/video-clipping-service-implementation-plan.md index 1ce324a..31b000d 100644 --- a/docs/video-clipping-service-implementation-plan.md +++ b/docs/video-clipping-service-implementation-plan.md @@ -711,8 +711,8 @@ Keep the domain independent from Spring framework details. Adapters implement st ### Performance Tuning 1. [x] Worker CPU/disk benchmarks. -2. [ ] FFmpeg preset benchmarks. -3. [ ] Object-storage bandwidth tests. +2. [x] FFmpeg preset benchmarks. +3. [x] Object-storage bandwidth tests. 4. [ ] Queue visibility-timeout tuning. 5. [ ] Autoscaling policies. 6. [ ] Cost model by video minute. diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index ee9909a..0b0c980 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -213,6 +213,7 @@ public class VideoClippingProperties { private String ffmpegBinary = "ffmpeg"; private String inputDirectory = "./tmp/ffmpeg-input"; private String outputDirectory = "./tmp/ffmpeg-output"; + private String exactPreset = "veryfast"; private boolean cleanupLocalFiles = true; public String getFfmpegBinary() { @@ -239,6 +240,14 @@ public class VideoClippingProperties { this.outputDirectory = outputDirectory; } + public String getExactPreset() { + return exactPreset; + } + + public void setExactPreset(String exactPreset) { + this.exactPreset = exactPreset; + } + public boolean isCleanupLocalFiles() { return cleanupLocalFiles; } diff --git a/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java b/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java index f71c1bd..2d100b6 100644 --- a/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java +++ b/src/main/java/org/example/videoclips/processing/FfmpegVideoClipperAdapter.java @@ -87,7 +87,7 @@ public class FfmpegVideoClipperAdapter implements VideoClipperPort { command.add("-c:v"); command.add("libx264"); command.add("-preset"); - command.add("veryfast"); + command.add(properties.getFfmpeg().getExactPreset()); command.add("-crf"); command.add("20"); command.add("-force_key_frames"); diff --git a/src/test/java/org/example/videoclips/perf/FfmpegPresetBenchmarkHarness.java b/src/test/java/org/example/videoclips/perf/FfmpegPresetBenchmarkHarness.java new file mode 100644 index 0000000..576667d --- /dev/null +++ b/src/test/java/org/example/videoclips/perf/FfmpegPresetBenchmarkHarness.java @@ -0,0 +1,148 @@ +package org.example.videoclips.perf; + +import org.example.videoclips.config.VideoClippingProperties; +import org.example.videoclips.domain.AccuracyMode; +import org.example.videoclips.processing.FfmpegVideoClipperAdapter; +import org.example.videoclips.processing.GeneratedClip; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +class FfmpegPresetBenchmarkHarness { + + @Test + void benchmarkExactModePresets() throws IOException { + List presets = List.of("ultrafast", "veryfast", "medium"); + Path reportDir = Path.of("target/benchmarks"); + Files.createDirectories(reportDir); + + Path sharedRoot = Path.of("tmp/benchmarks/ffmpeg-presets"); + Path inputDir = sharedRoot.resolve("input"); + Path outputDir = sharedRoot.resolve("output"); + Files.createDirectories(inputDir); + Files.createDirectories(outputDir); + + String sourceObjectKey = "uploads/bench-preset/source.mp4"; + Path inputFile = inputDir.resolve(sourceObjectKey); + Files.createDirectories(inputFile.getParent()); + createSyntheticInput(inputFile); + + StringBuilder builder = new StringBuilder(); + builder.append("# FFmpeg Preset Benchmark Baseline\n\n"); + builder.append("Methodology:\n"); + builder.append("- Real `ffmpeg` execution through `FfmpegVideoClipperAdapter`\n"); + builder.append("- Synthetic 16-second 1280x720 input generated with `testsrc`\n"); + builder.append("- `EXACT` mode with 8-second segmentation\n"); + builder.append("- Presets compared: `ultrafast`, `veryfast`, `medium`\n\n"); + builder.append("| Preset | Clip Count | Wall ms | Output Bytes |\n"); + builder.append("| --- | ---: | ---: | ---: |\n"); + + for (String preset : presets) { + BenchmarkResult result = runPresetBenchmark(preset, inputDir, outputDir, sourceObjectKey); + builder.append("| ") + .append(result.preset()).append(" | ") + .append(result.clipCount()).append(" | ") + .append(String.format(Locale.ROOT, "%.2f", result.wallMillis())).append(" | ") + .append(result.outputBytes()).append(" |\n"); + } + + Path generatedReport = reportDir.resolve("ffmpeg-preset-benchmark.md"); + Files.writeString(generatedReport, builder.toString()); + System.out.println("FFmpeg preset benchmark report written to " + generatedReport.toAbsolutePath()); + + deleteRecursively(sharedRoot); + } + + private BenchmarkResult runPresetBenchmark(String preset, Path inputDir, Path outputDir, String sourceObjectKey) throws IOException { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getFfmpeg().setFfmpegBinary("ffmpeg"); + properties.getFfmpeg().setInputDirectory(inputDir.toString()); + properties.getFfmpeg().setOutputDirectory(outputDir.toString()); + properties.getFfmpeg().setExactPreset(preset); + + FfmpegVideoClipperAdapter adapter = new FfmpegVideoClipperAdapter(properties); + String jobId = "job_preset_" + preset; + Path scenarioOutputDir = outputDir.resolve(jobId); + + deleteRecursively(scenarioOutputDir); + + long wallStart = System.nanoTime(); + adapter.generateClips(jobId, "asset-bench", sourceObjectKey, 16, 8, AccuracyMode.EXACT); + long wallElapsed = System.nanoTime() - wallStart; + + long clipCount; + long outputBytes; + try (Stream stream = Files.list(scenarioOutputDir)) { + List outputs = stream.filter(Files::isRegularFile).sorted().toList(); + clipCount = outputs.size(); + outputBytes = outputs.stream() + .mapToLong(path -> { + try { + return Files.size(path); + } catch (IOException ex) { + throw new IllegalStateException("Unable to read output clip size", ex); + } + }) + .sum(); + } + + deleteRecursively(scenarioOutputDir); + return new BenchmarkResult(preset, (int) clipCount, wallElapsed / 1_000_000.0, outputBytes); + } + + private void createSyntheticInput(Path inputFile) throws IOException { + deleteRecursively(inputFile.getParent()); + Files.createDirectories(inputFile.getParent()); + ProcessBuilder processBuilder = new ProcessBuilder( + "ffmpeg", + "-hide_banner", + "-y", + "-f", "lavfi", + "-i", "testsrc=size=1280x720:rate=30", + "-f", "lavfi", + "-i", "sine=frequency=1000:sample_rate=48000", + "-t", "16", + "-c:v", "libx264", + "-preset", "ultrafast", + "-pix_fmt", "yuv420p", + "-c:a", "aac", + inputFile.toString() + ); + processBuilder.redirectErrorStream(true); + try { + Process process = processBuilder.start(); + String output = new String(process.getInputStream().readAllBytes()); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IllegalStateException("Unable to generate synthetic ffmpeg input: " + output); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Synthetic ffmpeg input generation interrupted", ex); + } + } + + private void deleteRecursively(Path path) throws IOException { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream stream = Files.walk(path)) { + stream.sorted(Comparator.reverseOrder()).forEach(file -> { + try { + Files.deleteIfExists(file); + } catch (IOException ex) { + throw new IllegalStateException("Unable to delete benchmark path " + file, ex); + } + }); + } + } + + private record BenchmarkResult(String preset, int clipCount, double wallMillis, long outputBytes) { + } +} diff --git a/src/test/java/org/example/videoclips/perf/ObjectStorageBandwidthBenchmarkHarness.java b/src/test/java/org/example/videoclips/perf/ObjectStorageBandwidthBenchmarkHarness.java new file mode 100644 index 0000000..ffcca36 --- /dev/null +++ b/src/test/java/org/example/videoclips/perf/ObjectStorageBandwidthBenchmarkHarness.java @@ -0,0 +1,288 @@ +package org.example.videoclips.perf; + +import org.example.videoclips.storage.ObjectStoragePort; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.locks.LockSupport; +import java.util.stream.Stream; + +class ObjectStorageBandwidthBenchmarkHarness { + + private static final int MEBIBYTE = 1024 * 1024; + + @Test + void benchmarkObjectStorageBandwidthProfiles() throws IOException { + Path reportDir = Path.of("target/benchmarks"); + Files.createDirectories(reportDir); + + Path sharedRoot = Path.of("tmp/benchmarks/object-storage-bandwidth"); + deleteRecursively(sharedRoot); + Files.createDirectories(sharedRoot); + + Path storageRoot = sharedRoot.resolve("storage"); + Path workerInputRoot = sharedRoot.resolve("worker-input"); + Path workerClipRoot = sharedRoot.resolve("worker-clips"); + Files.createDirectories(storageRoot); + Files.createDirectories(workerInputRoot); + Files.createDirectories(workerClipRoot); + + String sourceObjectKey = "uploads/bandwidth/source.bin"; + Path sourceObject = storageRoot.resolve(sourceObjectKey); + createDeterministicFile(sourceObject, 64L * MEBIBYTE, (byte) 0x11); + + List generatedClips = List.of( + workerClipRoot.resolve("clip-01.mp4"), + workerClipRoot.resolve("clip-02.mp4"), + workerClipRoot.resolve("clip-03.mp4"), + workerClipRoot.resolve("clip-04.mp4") + ); + for (int i = 0; i < generatedClips.size(); i++) { + createDeterministicFile(generatedClips.get(i), 8L * MEBIBYTE, (byte) (0x20 + i)); + } + + List profiles = List.of( + new Profile("local-disk", 0), + new Profile("100 MiB/s", 100), + new Profile("50 MiB/s", 50), + new Profile("25 MiB/s", 25) + ); + + List results = profiles.stream() + .map(profile -> runBenchmark(profile, storageRoot, workerInputRoot, sourceObjectKey, generatedClips)) + .toList(); + + Path reportPath = reportDir.resolve("object-storage-bandwidth-benchmark.md"); + Files.writeString(reportPath, renderReport(results, sourceObject, generatedClips)); + System.out.println("Object-storage bandwidth benchmark report written to " + reportPath.toAbsolutePath()); + + deleteRecursively(sharedRoot); + } + + private BenchmarkResult runBenchmark( + Profile profile, + Path storageRoot, + Path workerInputRoot, + String sourceObjectKey, + List generatedClips + ) { + ObjectStoragePort storage = profile.bandwidthMiBPerSecond() > 0 + ? new ThrottledObjectStorageAdapter(storageRoot, profile.bandwidthMiBPerSecond()) + : new FileCopyObjectStorageAdapter(storageRoot); + + Path localInputPath = workerInputRoot.resolve(profile.slug()).resolve(sourceObjectKey); + try { + deleteRecursively(localInputPath.getParent()); + Files.createDirectories(localInputPath.getParent()); + } catch (IOException ex) { + throw new IllegalStateException("Unable to prepare worker input directory", ex); + } + + long sourceBytes = sizeOf(storageRoot.resolve(sourceObjectKey)); + long uploadedBytes = generatedClips.stream().mapToLong(this::sizeOf).sum(); + + long materializeStart = System.nanoTime(); + storage.materializeSourceObject(sourceObjectKey, localInputPath); + long materializeElapsed = System.nanoTime() - materializeStart; + + long uploadStart = System.nanoTime(); + for (int i = 0; i < generatedClips.size(); i++) { + storage.uploadGeneratedClip(generatedClips.get(i), "clips/" + profile.slug() + "/clip-" + (i + 1) + ".mp4"); + } + long uploadElapsed = System.nanoTime() - uploadStart; + + return new BenchmarkResult( + profile.label(), + sourceBytes, + uploadedBytes, + materializeElapsed / 1_000_000.0, + uploadElapsed / 1_000_000.0, + throughputMiBPerSecond(sourceBytes, materializeElapsed), + throughputMiBPerSecond(uploadedBytes, uploadElapsed) + ); + } + + private double throughputMiBPerSecond(long bytes, long elapsedNanos) { + if (elapsedNanos <= 0L) { + return 0.0; + } + double seconds = elapsedNanos / 1_000_000_000.0; + return (bytes / (double) MEBIBYTE) / seconds; + } + + private long sizeOf(Path path) { + try { + return Files.size(path); + } catch (IOException ex) { + throw new IllegalStateException("Unable to determine file size for " + path, ex); + } + } + + private void createDeterministicFile(Path path, long sizeBytes, byte seed) throws IOException { + Files.createDirectories(path.getParent()); + byte[] block = new byte[MEBIBYTE]; + for (int i = 0; i < block.length; i++) { + block[i] = (byte) (seed + i); + } + try (var outputStream = Files.newOutputStream(path)) { + long remaining = sizeBytes; + while (remaining > 0) { + int bytesToWrite = (int) Math.min(block.length, remaining); + outputStream.write(block, 0, bytesToWrite); + remaining -= bytesToWrite; + } + } + } + + private void deleteRecursively(Path path) throws IOException { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream stream = Files.walk(path)) { + stream.sorted(Comparator.reverseOrder()).forEach(file -> { + try { + Files.deleteIfExists(file); + } catch (IOException ex) { + throw new IllegalStateException("Unable to delete benchmark path " + file, ex); + } + }); + } + } + + private String renderReport(List results, Path sourceObject, List generatedClips) { + long sourceMiB = sizeOf(sourceObject) / MEBIBYTE; + long totalClipMiB = generatedClips.stream().mapToLong(this::sizeOf).sum() / MEBIBYTE; + + StringBuilder builder = new StringBuilder(); + builder.append("# Object Storage Bandwidth Benchmark Baseline\n\n"); + builder.append("Methodology:\n"); + builder.append("- Local file-copy adapter used as the storage baseline for the `ObjectStoragePort`\n"); + builder.append("- Optional throttling layer simulates capped object-storage throughput without a live S3 dependency\n"); + builder.append("- Source materialization downloads one ").append(sourceMiB).append(" MiB object into worker input storage\n"); + builder.append("- Generated clip upload writes four clips totaling ").append(totalClipMiB).append(" MiB back to object storage\n\n"); + builder.append("| Profile | Source MiB | Uploaded MiB | Materialize ms | Upload ms | Materialize MiB/s | Upload MiB/s |\n"); + builder.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n"); + for (BenchmarkResult result : results) { + builder.append("| ") + .append(result.profile()).append(" | ") + .append(result.sourceBytes() / MEBIBYTE).append(" | ") + .append(result.uploadedBytes() / MEBIBYTE).append(" | ") + .append(String.format(Locale.ROOT, "%.2f", result.materializeMillis())).append(" | ") + .append(String.format(Locale.ROOT, "%.2f", result.uploadMillis())).append(" | ") + .append(String.format(Locale.ROOT, "%.2f", result.materializeMiBPerSecond())).append(" | ") + .append(String.format(Locale.ROOT, "%.2f", result.uploadMiBPerSecond())).append(" |\n"); + } + return builder.toString(); + } + + private record Profile(String label, int bandwidthMiBPerSecond) { + private String slug() { + return label.toLowerCase(Locale.ROOT).replace(" ", "-").replace("/", "-"); + } + } + + private record BenchmarkResult( + String profile, + long sourceBytes, + long uploadedBytes, + double materializeMillis, + double uploadMillis, + double materializeMiBPerSecond, + double uploadMiBPerSecond + ) { + } + + private static class FileCopyObjectStorageAdapter implements ObjectStoragePort { + + protected final Path storageRoot; + + private FileCopyObjectStorageAdapter(Path storageRoot) { + this.storageRoot = storageRoot; + } + + @Override + public UploadSessionDescriptor createMultipartUpload(String uploadId, int partCount, long partSizeBytes, Instant expiresAt) { + throw new UnsupportedOperationException("Not needed for benchmark"); + } + + @Override + public void completeMultipartUpload(String providerUploadId, String uploadId, List parts) { + throw new UnsupportedOperationException("Not needed for benchmark"); + } + + @Override + public void materializeSourceObject(String sourceObjectKey, Path targetPath) { + copy(storageRoot.resolve(sourceObjectKey), targetPath); + } + + @Override + public void uploadGeneratedClip(Path sourcePath, String objectKey) { + copy(sourcePath, storageRoot.resolve(objectKey)); + } + + @Override + public void deleteObject(String objectKey) { + throw new UnsupportedOperationException("Not needed for benchmark"); + } + + @Override + public String createClipDownloadUrl(String objectKey, Instant expiresAt) { + throw new UnsupportedOperationException("Not needed for benchmark"); + } + + private void copy(Path source, Path target) { + try { + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException ex) { + throw new IllegalStateException("Unable to copy benchmark object", ex); + } + } + } + + private static final class ThrottledObjectStorageAdapter extends FileCopyObjectStorageAdapter { + + private final int bandwidthMiBPerSecond; + + private ThrottledObjectStorageAdapter(Path storageRoot, int bandwidthMiBPerSecond) { + super(storageRoot); + this.bandwidthMiBPerSecond = bandwidthMiBPerSecond; + } + + @Override + public void materializeSourceObject(String sourceObjectKey, Path targetPath) { + long bytes = sizeOfUnchecked(super.storageRoot.resolve(sourceObjectKey)); + super.materializeSourceObject(sourceObjectKey, targetPath); + throttle(bytes); + } + + @Override + public void uploadGeneratedClip(Path sourcePath, String objectKey) { + long bytes = sizeOfUnchecked(sourcePath); + super.uploadGeneratedClip(sourcePath, objectKey); + throttle(bytes); + } + + private void throttle(long bytes) { + long nanos = (long) ((bytes / (double) MEBIBYTE) / bandwidthMiBPerSecond * 1_000_000_000L); + if (nanos > 0L) { + LockSupport.parkNanos(nanos); + } + } + + private long sizeOfUnchecked(Path path) { + try { + return Files.size(path); + } catch (IOException ex) { + throw new IllegalStateException("Unable to determine benchmark object size", ex); + } + } + } +}