This is an automated email from the ASF dual-hosted git repository.

pitrou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow.git


The following commit(s) were added to refs/heads/main by this push:
     new cb3bd69278 GH-49913: [Archery] Add preserve-dir and improve directory 
layout (#50056)
cb3bd69278 is described below

commit cb3bd692784bcb0872af2c25df66a1d2f3564e02
Author: Antoine Prouvost <[email protected]>
AuthorDate: Tue Jun 30 17:24:41 2026 +0200

    GH-49913: [Archery] Add preserve-dir and improve directory layout (#50056)
    
    ### Rationale for this change
    Improve workflow when running many benchmarks.
    Namely eagerly store results and avoid user having to manage large amount 
of file names.
    
    ### What changes are included in this PR?
    With the following code run **twice**,
    ```bash
    archery benchmark run --suite-filter=parquet --benchmark-filter=Decode 
--preserve --output out.json HEAD --preserve --preserve-dir  bench/
    ```
    The `preserve-dir` is used and its internal structure is as follows.
    
    ```
    bench
    └── 0202ab5295fee14c6c7b85b05ca78cc5c281b3a5
        ├── arrow
        │   └── // Source files...
        ├── bench
        │   ├── 84e0ab99
        │   │   ├── benchmark.json
        │   │   └── metadata.json
        │   └── e15e438a
        │       ├── benchmark.json
        │       └── metadata.json
        └── build
            ├── 84e0ab99
            │   └── // Cmake build files...
            └── e15e438a
                └── // Cmake build files...
    ```
    
    The commit is uniquerly resolved source is reused between runs.
    Separate invocation results in separate builds and benchmark execution.
    The idea would be to be able to reuse the build dir but it is tricky to 
tell when it should be changed (If CLI params are different? If environment var 
change? If dependencies change?).
    
    The idea is to get started minimally without to much maintenance.
    
    Metadata are also a starting point to avoid forgetting what a (costly!) run 
was about.
    Example from current run:
    
    ```json
    {
      "time": "2026-05-27T13:58:02.792064+00:00",
      "cmd": {
        "full": "archery benchmark run --suite-filter=parquet 
--benchmark-filter=Decode --preserve --output out.json HEAD --preserve 
--preserve-dir bench/",
        "params": {
          "suite_filter": "parquet",
          "benchmark_filter": "Decode",
          "preserve": true,
          "output": "<unopened file 'out.json' w>",
          "preserve_dir": ".../arrow/bench",
          "rev_or_path": "HEAD",
          "cpp_benchmark_extras": [],
          "cmake_extras": [],
          "benchmark_extras": [],
          "build_extras": [],
          "language": "cpp",
          "src": ".../arrow",
          "cpp_package_prefix": null,
          "cxx_flags": null,
          "cxx": null,
          "cc": null,
          "java_options": null,
          "java_home": null,
          "repetitions": -1,
          "repetition_min_time": null
        }
      },
      "machine_info": {
        "platform": "macOS-26.5-arm64-arm-64bit-Mach-O",
        "system": "Darwin",
        "release": "25.5.0",
        "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:06 PDT 2026; 
root:xnu-12377.121.6~2/RELEASE_ARM64_T6030",
        "machine": "arm64",
        "processor": "arm",
        "architecture": "64bit",
        "logical_cores": 12
      }
    }
    ```
    
    ### Are these changes tested?
    Yes
    
    ### Are there any user-facing changes?
    Archery user that looked into the `--preserve` directory.
    
    * GitHub Issue: #49913
    
    Authored-by: AntoinePrv <[email protected]>
    Signed-off-by: Antoine Pitrou <[email protected]>
---
 dev/archery/archery/benchmark/runner.py | 76 +++++++++++++++++++++++++--------
 dev/archery/archery/cli.py              | 76 ++++++++++++++++++++++++++-------
 dev/archery/archery/utils/tmpdir.py     |  8 +++-
 3 files changed, 125 insertions(+), 35 deletions(-)

diff --git a/dev/archery/archery/benchmark/runner.py 
b/dev/archery/archery/benchmark/runner.py
index 571a5e5839..346ff6783b 100644
--- a/dev/archery/archery/benchmark/runner.py
+++ b/dev/archery/archery/benchmark/runner.py
@@ -15,10 +15,13 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import datetime
 import glob
 import json
 import os
+import random
 import re
+import subprocess
 
 from .core import BenchmarkSuite
 from .google import GoogleBenchmarkCommand, GoogleBenchmark
@@ -26,8 +29,28 @@ from .jmh import JavaMicrobenchmarkHarnessCommand, 
JavaMicrobenchmarkHarness
 from ..lang.cpp import CppCMakeDefinition, CppConfiguration
 from ..lang.java import JavaMavenDefinition, JavaConfiguration
 from ..utils.cmake import CMakeBuild
+from ..utils.git import git
 from ..utils.maven import MavenBuild
 from ..utils.logger import logger
+from ..utils.source import ArrowSources
+
+
+def _rev_or_path_dirname(src, rev_or_path):
+    """Return a directory-name-safe identifier for a revision or a path.
+
+    If ``rev_or_path`` resolves to a git revision, its full SHA is returned.
+    Otherwise (e.g. it is a filesystem path), a sanitized form with path
+    separators replaced is returned.
+    """
+    if rev_or_path == ArrowSources.WORKSPACE:
+        return rev_or_path
+    try:
+        sha = git.rev_parse(rev_or_path, git_dir=src.path)
+        if isinstance(sha, bytes):
+            sha = sha.decode("ascii")
+        return sha
+    except subprocess.CalledProcessError:
+        return rev_or_path.replace("/", "_")
 
 
 def regex_filter(re_expr):
@@ -47,6 +70,7 @@ class BenchmarkRunner:
         self.benchmark_filter = benchmark_filter
         self.repetitions = repetitions
         self.repetition_min_time = repetition_min_time
+        self.results_dir = None
 
     @property
     def suites(self):
@@ -108,11 +132,14 @@ class StaticBenchmarkRunner(BenchmarkRunner):
 class CppBenchmarkRunner(BenchmarkRunner):
     """ Run suites from a CMakeBuild. """
 
-    def __init__(self, build, benchmark_extras, **kwargs):
+    def __init__(self, build, benchmark_extras, run_id=None, results_dir=None,
+                 **kwargs):
         """ Initialize a CppBenchmarkRunner. """
+        super().__init__(**kwargs)
         self.build = build
         self.benchmark_extras = benchmark_extras
-        super().__init__(**kwargs)
+        self.run_id = run_id
+        self.results_dir = results_dir
 
     @staticmethod
     def default_configuration(**kwargs):
@@ -217,19 +244,33 @@ class CppBenchmarkRunner(BenchmarkRunner):
             build = CMakeBuild.from_path(rev_or_path)
             return CppBenchmarkRunner(build, **kwargs)
         else:
-            # Revisions can references remote via the `/` character, ensure
-            # that the revision is path friendly
-            path_rev = rev_or_path.replace("/", "_")
+            path_rev = _rev_or_path_dirname(src, rev_or_path)
             root_rev = os.path.join(root, path_rev)
-            os.mkdir(root_rev)
+            os.makedirs(root_rev, exist_ok=True)
 
+            # Clone dir is reused when there is known revision (git sha)
+            # in <preserve-dir>/sha/arrow
             clone_dir = os.path.join(root_rev, "arrow")
-            # Possibly checkout the sources at given revision, no need to
-            # perform cleanup on cloned repository as root_rev is reclaimed.
-            src_rev, _ = src.at_revision(rev_or_path, clone_dir)
+            if os.path.isdir(clone_dir):
+                src_rev = ArrowSources(clone_dir)
+            else:
+                src_rev, _ = src.at_revision(rev_or_path, clone_dir)
             cmake_def = CppCMakeDefinition(src_rev.cpp, cmake_conf)
-            build_dir = os.path.join(root_rev, "build")
-            return CppBenchmarkRunner(cmake_def.build(build_dir), **kwargs)
+            run_root = os.path.join(root_rev, "build")
+            os.makedirs(run_root, exist_ok=True)
+            # run_id is a path-safe ISO-8601 UTC timestamp down to the second
+            # so users get a brief idea of what run they are looking at, plus a
+            # random suffix to avoid collisions between runs in the same 
second.
+            now = datetime.datetime.now(datetime.timezone.utc)
+            run_id = now.strftime("%Y-%m-%dT%H-%M-%SZ") + \
+                f"-{random.randrange(16**8):08x}"
+            build_dir = os.path.join(run_root, run_id)
+            build = cmake_def.build(build_dir)
+            results_dir = os.path.join(root_rev, "bench", run_id)
+            os.makedirs(results_dir, exist_ok=True)
+            return CppBenchmarkRunner(
+                build, run_id=run_id, results_dir=results_dir, **kwargs
+            )
 
 
 class JavaBenchmarkRunner(BenchmarkRunner):
@@ -310,16 +351,15 @@ class JavaBenchmarkRunner(BenchmarkRunner):
             maven_def = JavaMavenDefinition(rev_or_path, maven_conf)
             return JavaBenchmarkRunner(maven_def.build(rev_or_path), **kwargs)
         else:
-            # Revisions can references remote via the `/` character, ensure
-            # that the revision is path friendly
-            path_rev = rev_or_path.replace("/", "_")
+            path_rev = _rev_or_path_dirname(src, rev_or_path)
             root_rev = os.path.join(root, path_rev)
-            os.mkdir(root_rev)
+            os.makedirs(root_rev, exist_ok=True)
 
             clone_dir = os.path.join(root_rev, "arrow")
-            # Possibly checkout the sources at given revision, no need to
-            # perform cleanup on cloned repository as root_rev is reclaimed.
-            src_rev, _ = src.at_revision(rev_or_path, clone_dir)
+            if os.path.isdir(clone_dir):
+                src_rev = ArrowSources(clone_dir)
+            else:
+                src_rev, _ = src.at_revision(rev_or_path, clone_dir)
             maven_def = JavaMavenDefinition(src_rev.java, maven_conf)
             build_dir = os.path.join(root_rev, "arrow/java")
             return JavaBenchmarkRunner(maven_def.build(build_dir), **kwargs)
diff --git a/dev/archery/archery/cli.py b/dev/archery/archery/cli.py
index e70cc3874f..e7dcc81950 100644
--- a/dev/archery/archery/cli.py
+++ b/dev/archery/archery/cli.py
@@ -17,10 +17,12 @@
 
 from io import StringIO
 import click
+import datetime
 import json
 import logging
 import os
 import pathlib
+import platform
 import sys
 
 from .benchmark.codec import JsonEncoder
@@ -41,6 +43,27 @@ logging.basicConfig(level=logging.INFO)
 BOOL = ArrowBool()
 
 
+def _run_metadata(ctx):
+    uname = platform.uname()
+    return {
+        "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+        "cmd": {
+            "argv": sys.argv,
+            "params": ctx.params,
+        },
+        "machine_info": {
+            "platform": platform.platform(),
+            "system": uname.system,
+            "release": uname.release,
+            "version": uname.version,
+            "machine": platform.machine(),
+            "processor": platform.processor(),
+            "architecture": platform.architecture()[0],
+            "logical_cores": os.cpu_count(),
+        },
+    }
+
+
 @click.group(context_settings={"help_option_names": ["-h", "--help"]})
 @click.option("--debug", type=BOOL, is_flag=True, default=False,
               envvar='ARCHERY_DEBUG',
@@ -305,6 +328,11 @@ def benchmark_common_options(cmd):
         click.option("--preserve", type=BOOL, default=False, show_default=True,
                      is_flag=True,
                      help="Preserve workspace for investigation."),
+        click.option("--preserve-dir", metavar="<path>",
+                     type=click.Path(file_okay=False, resolve_path=True),
+                     default=None,
+                     help="Parent directory in which to create the preserved "
+                     "workspace. Implies --preserve."),
         click.option("--output", metavar="<output>",
                      type=click.File("w", encoding="utf8"), default=None,
                      help="Capture output result into file."),
@@ -347,12 +375,14 @@ def benchmark_filter_options(cmd):
                 default="WORKSPACE", required=False)
 @benchmark_common_options
 @click.pass_context
-def benchmark_list(ctx, rev_or_path, src, preserve, output, cmake_extras,
-                   java_home, java_options, build_extras, benchmark_extras,
-                   cpp_benchmark_extras, language, **kwargs):
+def benchmark_list(ctx, rev_or_path, src, preserve, preserve_dir, output,
+                   cmake_extras, java_home, java_options, build_extras,
+                   benchmark_extras, cpp_benchmark_extras, language, **kwargs):
     """ List benchmark suite.
     """
-    with tmpdir(preserve=preserve) as root:
+    # A preserve_dir implies preserving the workspace.
+    preserve = preserve or preserve_dir is not None
+    with tmpdir(preserve=preserve, preserve_dir=preserve_dir) as root:
         logger.debug(f"Running benchmark {rev_or_path}")
 
         if language == "cpp":
@@ -392,10 +422,11 @@ def benchmark_list(ctx, rev_or_path, src, preserve, 
output, cmake_extras,
                     "Currently only supported for language=cpp. "
                     "[default: use runner-specific defaults]"))
 @click.pass_context
-def benchmark_run(ctx, rev_or_path, src, preserve, output, cmake_extras,
-                  java_home, java_options, build_extras, benchmark_extras,
-                  language, suite_filter, benchmark_filter, repetitions,
-                  repetition_min_time, cpp_benchmark_extras, **kwargs):
+def benchmark_run(ctx, rev_or_path, src, preserve, preserve_dir, output,
+                  cmake_extras, java_home, java_options, build_extras,
+                  benchmark_extras, language, suite_filter, benchmark_filter,
+                  repetitions, repetition_min_time, cpp_benchmark_extras,
+                  **kwargs):
     """ Run benchmark suite.
 
     This command will run the benchmark suite for a single build. This is
@@ -433,7 +464,9 @@ def benchmark_run(ctx, rev_or_path, src, preserve, output, 
cmake_extras,
     \b
     archery benchmark run --output=run.json
     """
-    with tmpdir(preserve=preserve) as root:
+    # A preserve_dir implies preserving the workspace.
+    preserve = preserve or preserve_dir is not None
+    with tmpdir(preserve=preserve, preserve_dir=preserve_dir) as root:
         logger.debug(f"Running benchmark {rev_or_path}")
 
         if language == "cpp":
@@ -465,6 +498,17 @@ def benchmark_run(ctx, rev_or_path, src, preserve, output, 
cmake_extras,
         # when asked to JSON-serialize the results, so produce a JSON
         # output even when none is requested.
         json_out = json.dumps(runner_base, cls=JsonEncoder)
+        if runner_base.results_dir is not None:
+            results_path = os.path.join(runner_base.results_dir,
+                                        "benchmark.json")
+            with open(results_path, "w") as f:
+                f.write(json_out)
+            # Store some run metadata to make it hard to confuse runs, for 
instance
+            # remembering what was the SIMD level set on this run.
+            metadata_path = os.path.join(runner_base.results_dir,
+                                         "metadata.json")
+            with open(metadata_path, "w") as f:
+                json.dump(_run_metadata(ctx), f, indent=2, default=str)
         if output is not None:
             output.write(json_out)
 
@@ -486,11 +530,11 @@ def benchmark_run(ctx, rev_or_path, src, preserve, 
output, cmake_extras,
 @click.argument("baseline", metavar="[<baseline>]]", default="origin/HEAD",
                 required=False)
 @click.pass_context
-def benchmark_diff(ctx, src, preserve, output, language, cmake_extras,
-                   suite_filter, benchmark_filter, repetitions, no_counters,
-                   java_home, java_options, build_extras, benchmark_extras,
-                   cpp_benchmark_extras, threshold, contender, baseline,
-                   **kwargs):
+def benchmark_diff(ctx, src, preserve, preserve_dir, output, language,
+                   cmake_extras, suite_filter, benchmark_filter, repetitions,
+                   no_counters, java_home, java_options, build_extras,
+                   benchmark_extras, cpp_benchmark_extras, threshold,
+                   contender, baseline, **kwargs):
     """Compare (diff) benchmark runs.
 
     This command acts like git-diff but for benchmark results.
@@ -564,7 +608,9 @@ def benchmark_diff(ctx, src, preserve, output, language, 
cmake_extras,
     # This should not recompute the benchmark from run.json
     archery --quiet benchmark diff WORKSPACE run.json > result.json
     """
-    with tmpdir(preserve=preserve) as root:
+    # A preserve_dir implies preserving the workspace.
+    preserve = preserve or preserve_dir is not None
+    with tmpdir(preserve=preserve, preserve_dir=preserve_dir) as root:
         logger.debug(f"Comparing {contender} (contender) with {baseline} 
(baseline)")
 
         if language == "cpp":
diff --git a/dev/archery/archery/utils/tmpdir.py 
b/dev/archery/archery/utils/tmpdir.py
index 07d7355c87..06eb9a6ba6 100644
--- a/dev/archery/archery/utils/tmpdir.py
+++ b/dev/archery/archery/utils/tmpdir.py
@@ -15,13 +15,17 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import os
 from contextlib import contextmanager
 from tempfile import mkdtemp, TemporaryDirectory
 
 
 @contextmanager
-def tmpdir(preserve=False, prefix="arrow-archery-"):
-    if preserve:
+def tmpdir(preserve=False, prefix="arrow-archery-", preserve_dir=None):
+    if preserve and preserve_dir is not None:
+        os.makedirs(preserve_dir, exist_ok=True)
+        yield preserve_dir
+    elif preserve:
         yield mkdtemp(prefix=prefix)
     else:
         with TemporaryDirectory(prefix=prefix) as tmp:

Reply via email to