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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/cx/BENCHKIT_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ Benchkit は、アプリが出力する詳細 timer table や profiler から得
`timing_observations` は最初の段階では任意項目であり、存在しない result を ingest failure として扱わない。
同じ job から複数 result が出る場合は、各 observation に `result_exp` などの result scope を添えてよい。
Result JSON には小さな summary と file reference を置き、巨大な profiler report や詳細 table は artifact として保持する。
Result sender は、`timing_observations` から参照された `results/*.json` と profiler archive を Measurement Artifacts として保存する。

将来、アプリ変更なしで詳細 timing を採取する場合は、profiler adapter が同じ `timing_observations` 経路へ summary と artifact reference を渡す。
その observation を `fom_breakdown` へ昇格するかどうかは、app / profiler ごとの mapping review によって決める。
Expand All @@ -553,6 +554,7 @@ This is an observation layer separate from `fom_breakdown`.
At the initial stage, `timing_observations` is optional, and results without it are not treated as ingest failures.
When one job emits multiple results, each observation may carry a result scope such as `result_exp`.
Result JSON should keep a compact summary and file reference; large profiler reports or full timer tables should remain artifacts.
The result sender stores referenced `results/*.json` timing files and profiler archives as Measurement Artifacts.

For app-change-free collection, profiler adapters may feed summaries and artifact references into the same `timing_observations` path.
Promoting an observation into `fom_breakdown` remains a separate mapping review for the app or profiler output.
Expand Down
5 changes: 3 additions & 2 deletions docs/guides/add-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,8 +474,8 @@ bk_emit_overlap compute_kernel,communication 0.05 >> results/result
`source_info` は必須ではありませんが、Git などから source を取得する app では `bk_fetch_source` を使って `results/source_info.env` を残すことを推奨します。
section / overlap / profiler archive は、詳細分析や推定を使う場合の任意拡張です。

### Performance Analysis データ(任意)
詳細データがある場合は `results/padata[0-9].tgz` として保存:
### Measurement Artifacts(任意)
詳細データがある場合、profiler archive は従来通り `results/padata[0-9].tgz` として保存できます:
```bash
# PAデータの作成例
mkdir -p pa
Expand All @@ -487,6 +487,7 @@ tar -czf ../results/padata0.tgz ./pa

Fugaku 系アプリでは、アプリ側が profiler tool を内部で選び、Benchkit 共通の `bk_profiler` helper に渡す形が扱いやすいです。
`bk_profiler` は profiler ごとの raw data / postprocess report をまとめて `results/padata*.tgz` に保存し、archive 内の `bk_profiler_artifact/meta.json` に metadata を入れます。Benchkit や推定 package はこの `meta.json` を見て、tool、level、report kind を機械的に判断できます。
`timing_observations` が `results/*.json` を参照する場合も、Result 送信時に同じ Measurement Artifacts として保存されます。

`fapp` では共通 level として次を扱います。

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/developer-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,5 @@ Treat missing `source_info`, `fom_breakdown`, or artifact references as follow-u
Detailed timing artifacts may be recorded through `timing_observations` before
they are promoted to `fom_breakdown`; do not treat every detailed timer or
profiler region as an additive estimation section without an app-specific
mapping review.
mapping review. Referenced `results/*.json` timing files and profiler archives
are uploaded as Measurement Artifacts by the result sender.
1 change: 1 addition & 0 deletions result_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def _configure_result_directories(app, base_dir):
dir_map = {
"RECEIVED_DIR": os.path.join(base_dir, "received"),
"RECEIVED_PADATA_DIR": os.path.join(base_dir, "received_padata"),
"RECEIVED_MEASUREMENT_ARTIFACTS_DIR": os.path.join(base_dir, "received_padata"),
"RECEIVED_ESTIMATION_ARTIFACTS_DIR": os.path.join(base_dir, "received_estimation_artifacts"),
"ESTIMATED_DIR": os.path.join(base_dir, "estimated_results"),
}
Expand Down
1 change: 1 addition & 0 deletions result_server/app_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def payload_too_large(_error):

app.config["RECEIVED_DIR"] = received_dir
app.config["RECEIVED_PADATA_DIR"] = received_padata_dir
app.config["RECEIVED_MEASUREMENT_ARTIFACTS_DIR"] = received_padata_dir
app.config["RECEIVED_ESTIMATION_ARTIFACTS_DIR"] = received_estimation_artifacts_dir
app.config["ESTIMATED_DIR"] = estimated_dir
app.config["EXECUTION_PROFILE_DB_PATH"] = os.environ.get(
Expand Down
97 changes: 72 additions & 25 deletions result_server/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@

api_bp = Blueprint("api", __name__)
_TIMESTAMP_RE = re.compile(r"^\d{8}_\d{6}$")
_MEASUREMENT_ARTIFACT_BASENAME_RE = re.compile(
r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)"
)
DEFAULT_MAX_ARCHIVE_MEMBER_SIZE = 1024 * 1024 * 1024
DEFAULT_MAX_ARCHIVE_TOTAL_EXTRACTED_SIZE = 1024 * 1024 * 1024
DEFAULT_MAX_ARCHIVE_MEMBER_COUNT = 4096
Expand Down Expand Up @@ -211,8 +214,12 @@ def _safe_basename(name):
return name


def _normalize_padata_artifact_slug(value):
"""Return a filename-safe padata artifact slug, or None for legacy uploads."""
def _normalize_measurement_artifact_basename(
value,
*,
error_message="Invalid measurement artifact path",
):
"""Return a filename-safe results/ artifact basename, or None."""
if value is None:
return None

Expand All @@ -226,16 +233,45 @@ def _normalize_padata_artifact_slug(value):
or "/../" in artifact_path
or artifact_path.endswith("/..")
):
abort(400, description="Invalid padata artifact path")
abort(400, description=error_message)
if not artifact_path.startswith("results/"):
abort(400, description="Invalid padata artifact path")
abort(400, description=error_message)

basename = os.path.basename(artifact_path)
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz)", basename):
abort(400, description="Invalid padata artifact path")
if basename.endswith(".tar.gz"):
return basename[:-7]
return basename[:-4]
if not _MEASUREMENT_ARTIFACT_BASENAME_RE.fullmatch(basename):
abort(400, description=error_message)
return basename


def _is_profile_archive_basename(basename):
return isinstance(basename, str) and (
basename.endswith(".tgz") or basename.endswith(".tar.gz")
)


def _copy_uploaded_file(uploaded_file, save_path):
"""Write an uploaded file atomically."""
tmp_path = save_path + ".tmp"
with open(tmp_path, "wb") as f:
shutil.copyfileobj(uploaded_file.stream, f, length=1024 * 1024)
f.flush()
os.fsync(f.fileno())
os.rename(tmp_path, save_path)


def _measurement_artifact_filename(timestamp, uuid_str, artifact_basename):
if artifact_basename is None:
return _safe_basename(f"padata_{timestamp}_{uuid_str}.tgz")
if _is_profile_archive_basename(artifact_basename):
artifact_slug = (
artifact_basename[:-7]
if artifact_basename.endswith(".tar.gz")
else artifact_basename[:-4]
)
return _safe_basename(f"padata_{timestamp}_{uuid_str}_{artifact_slug}.tgz")
return _safe_basename(
f"measurement_artifact_{timestamp}_{uuid_str}_{artifact_basename}"
)


def _load_json_by_uuid(directory, field_path, uuid_value):
Expand Down Expand Up @@ -434,10 +470,11 @@ def ingest_estimate():
return _saved_json_response(saved), 200


@api_bp.route("/api/ingest/measurement-artifact", methods=["POST"])
@api_bp.route("/api/ingest/padata", methods=["POST"])
@rate_limited(max_per_minute=120, key_fn=_api_rate_key, scope="api_ingest")
def ingest_padata():
"""Receive and store a PA Data archive."""
def ingest_measurement_artifact():
"""Receive and store a measurement artifact."""
runner_id = require_api_key()

uuid_str = request.form.get("id")
Expand All @@ -452,12 +489,23 @@ def ingest_padata():
if not uploaded_file:
abort(400, description="No file uploaded")

received_dir = current_app.config["RECEIVED_PADATA_DIR"]
artifact_slug = _normalize_padata_artifact_slug(request.form.get("artifact_path"))
received_dir = current_app.config.get(
"RECEIVED_MEASUREMENT_ARTIFACTS_DIR",
current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"]),
)
artifact_basename = _normalize_measurement_artifact_basename(
request.form.get("artifact_path")
)
if artifact_basename is None and not _is_profile_archive_basename(
uploaded_file.filename or ""
):
abort(400, description="Missing measurement artifact path")

if artifact_slug:
filename = _safe_basename(f"padata_{timestamp}_{uuid_str}_{artifact_slug}.tgz")
matched_files = [filename] if os.path.exists(os.path.join(received_dir, filename)) else []
if artifact_basename:
filename = _measurement_artifact_filename(timestamp, uuid_str, artifact_basename)
matched_files = (
[filename] if os.path.exists(os.path.join(received_dir, filename)) else []
)
else:
legacy_pattern = re.compile(rf"^padata_\d{{8}}_\d{{6}}_{re.escape(uuid_str)}\.tgz$")
matched_files = [
Expand All @@ -471,16 +519,11 @@ def ingest_padata():
shutil.move(old_file_path, backup_path)
save_path = old_file_path
else:
if not artifact_slug:
filename = _safe_basename(f"padata_{timestamp}_{uuid_str}.tgz")
if not artifact_basename:
filename = _measurement_artifact_filename(timestamp, uuid_str, None)
save_path = os.path.join(received_dir, filename)

tmp_path = save_path + ".tmp"
with open(tmp_path, "wb") as f:
shutil.copyfileobj(uploaded_file.stream, f, length=1024 * 1024)
f.flush()
os.fsync(f.fileno())
os.rename(tmp_path, save_path)
_copy_uploaded_file(uploaded_file, save_path)

print(f"Saved: {save_path}", flush=True)
response = {
Expand All @@ -496,7 +539,11 @@ def ingest_padata():
actor=runner_id,
target=response["file"],
result="success",
details={"ingest_type": "padata", "id": uuid_str, "replaced": response["replaced"]},
details={
"ingest_type": "measurement_artifact",
"id": uuid_str,
"replaced": response["replaced"],
},
)
return response, 200

Expand Down
124 changes: 114 additions & 10 deletions result_server/routes/results_detail_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@
PADATA_ARTIFACT_BASENAME_RE = re.compile(
r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz)"
)
MEASUREMENT_ARTIFACT_BASENAME_RE = re.compile(
r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)"
)
MEASUREMENT_ARTIFACT_FILENAME_RE = re.compile(
r"^measurement_artifact_\d{8}_\d{6}_"
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_"
r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)$",
re.IGNORECASE,
)


def register_results_detail_routes(results_bp):
Expand Down Expand Up @@ -79,15 +88,30 @@ def result_detail(filename):
not_found_message="Result file not found",
)
quality = summarize_result_quality(result)
padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"])
padata_filenames = _list_result_padata_filenames(result, padata_dir) if is_public_surface else [
name for name in os.listdir(padata_dir) if name.endswith(".tgz")
]
artifact_dir = current_app.config.get(
"RECEIVED_MEASUREMENT_ARTIFACTS_DIR",
current_app.config.get(
"RECEIVED_PADATA_DIR",
current_app.config["RECEIVED_DIR"],
),
)
measurement_artifact_filenames = (
_list_result_measurement_artifact_filenames(
result,
artifact_dir,
include_timing=False,
)
if is_public_surface
else [
name for name in os.listdir(artifact_dir)
if _is_measurement_artifact_filename(name)
]
)
detail_context = build_result_detail_context(
result,
quality,
load_trigger_run_lookup(current_app.config.get("EXECUTION_PROFILE_DB_PATH")),
padata_filenames,
measurement_artifact_filenames,
public_surface=is_public_surface,
)
public_result = not get_file_confidential_tags(filename, current_app.config["RECEIVED_DIR"])
Expand Down Expand Up @@ -134,7 +158,13 @@ def result_evidence_packet(filename):
not_found_message="Result file not found",
)
quality = summarize_result_quality(result)
padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"])
padata_dir = current_app.config.get(
"RECEIVED_MEASUREMENT_ARTIFACTS_DIR",
current_app.config.get(
"RECEIVED_PADATA_DIR",
current_app.config["RECEIVED_DIR"],
),
)
padata_filenames = [name for name in os.listdir(padata_dir) if name.endswith(".tgz")]
padata_urls = {
name: url_for("results.show_result", filename=name)
Expand Down Expand Up @@ -272,20 +302,32 @@ def show_result(filename):
return serve_public_padata_file(
filename,
current_app.config["RECEIVED_DIR"],
current_app.config["RECEIVED_PADATA_DIR"],
current_app.config.get(
"RECEIVED_MEASUREMENT_ARTIFACTS_DIR",
current_app.config["RECEIVED_PADATA_DIR"],
),
)
abort(404)

if filename.endswith(".tgz"):
if _is_measurement_artifact_filename(filename):
return serve_permitted_result_file(
filename,
current_app.config["RECEIVED_DIR"],
current_app.config["RECEIVED_PADATA_DIR"],
current_app.config.get(
"RECEIVED_MEASUREMENT_ARTIFACTS_DIR",
current_app.config["RECEIVED_PADATA_DIR"],
),
)
return serve_permitted_result_file(filename, current_app.config["RECEIVED_DIR"])

def _build_public_reuse_manifest_for_route(result, filename):
padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"])
padata_dir = current_app.config.get(
"RECEIVED_MEASUREMENT_ARTIFACTS_DIR",
current_app.config.get(
"RECEIVED_PADATA_DIR",
current_app.config["RECEIVED_DIR"],
),
)
padata_filenames = _list_result_padata_filenames(result, padata_dir)
padata_urls = {
name: url_for("results.show_result", filename=name)
Expand Down Expand Up @@ -323,6 +365,31 @@ def _list_result_padata_filenames(result, padata_dir):
return filenames


def _list_result_measurement_artifact_filenames(result, artifact_dir, *, include_timing=True):
result_uuid = _clean_result_value(result.get("_server_uuid"))
timestamp = _clean_result_value(result.get("_server_timestamp"))
if not result_uuid or not timestamp:
return []

filenames = []
seen = set()
for filename in _list_result_padata_filenames(result, artifact_dir):
seen.add(filename)
filenames.append(filename)

if not include_timing:
return filenames

for artifact_path in _iter_result_timing_artifact_paths(result):
filename = _measurement_artifact_filename(timestamp, result_uuid, artifact_path)
if not filename or filename in seen:
continue
seen.add(filename)
if os.path.isfile(os.path.join(artifact_dir, filename)):
filenames.append(filename)
return filenames


def _iter_result_padata_artifact_paths(result):
breakdown = result.get("fom_breakdown")
if not isinstance(breakdown, dict):
Expand All @@ -339,6 +406,21 @@ def _iter_result_padata_artifact_paths(result):
yield path


def _iter_result_timing_artifact_paths(result):
timing_observations = result.get("timing_observations")
if not isinstance(timing_observations, dict):
return
for observation in timing_observations.get("observations") or []:
if not isinstance(observation, dict):
continue
artifact = observation.get("artifact")
if not isinstance(artifact, dict) or artifact.get("type") != "file_reference":
continue
path = _clean_result_value(artifact.get("path"))
if path:
yield path


def _padata_artifact_slug(artifact_path):
if not isinstance(artifact_path, str) or not artifact_path.startswith("results/"):
return ""
Expand All @@ -348,5 +430,27 @@ def _padata_artifact_slug(artifact_path):
return basename[:-7] if basename.endswith(".tar.gz") else basename[:-4]


def _measurement_artifact_filename(timestamp, result_uuid, artifact_path):
basename = _measurement_artifact_basename(artifact_path)
if not basename:
return ""
return f"measurement_artifact_{timestamp}_{result_uuid}_{basename}"


def _measurement_artifact_basename(artifact_path):
if not isinstance(artifact_path, str) or not artifact_path.startswith("results/"):
return ""
basename = os.path.basename(artifact_path)
if not MEASUREMENT_ARTIFACT_BASENAME_RE.fullmatch(basename):
return ""
return basename


def _is_measurement_artifact_filename(filename):
return filename.endswith(".tgz") or bool(
MEASUREMENT_ARTIFACT_FILENAME_RE.fullmatch(filename)
)


def _clean_result_value(value):
return str(value or "").strip()
Loading
Loading