Files
SDR-Rover/experiments/lab053_receiver_profile.py

334 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Lab053: новый профиль Lab052 и точный файловый источник для конвейера."""
from __future__ import annotations
import argparse
from contextlib import contextmanager
from dataclasses import replace
import hashlib
import json
from pathlib import Path
import sys
import subprocess
import time
from types import SimpleNamespace
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
from experiments import lab052_candidate_detection as detection
from experiments import lab052_detector_study as study
from experiments.lab051_receiver_profile import StageProfiler
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "data/processed/lab053"
SOURCE = ROOT / "data/raw/lab053/source.c64"
def write_result(name: str, value: dict) -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
(OUTPUT / name).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def distribution(values) -> dict:
values = np.asarray(values, dtype=np.float64)
if not values.size:
return {"count": 0, "sum": 0.0, "mean": None, "median": None, "p95": None, "max": None}
return {"count": len(values), "sum": float(values.sum()), "mean": float(values.mean()),
"median": float(np.median(values)), "p95": float(np.quantile(values, .95)),
"max": float(values.max())}
class DetailedProfiler(StageProfiler):
"""Вложенные времена не складываются повторно; только отдельный замер."""
@contextmanager
def measure(self, *, optimized=True):
with super().measure(optimized=optimized):
patches = []
def wrap(function, name):
def measured(*args, **kwargs):
scope = self.shared if self.current is None else self.current
stage = scope[name]
started = time.perf_counter()
entry = [0.0]
self.stack.append(entry)
try:
return function(*args, **kwargs)
finally:
elapsed = time.perf_counter() - started
self.stack.pop()
if self.stack:
self.stack[-1][0] += elapsed
stage.calls += 1
stage.seconds += elapsed
stage.exclusive_seconds += elapsed - entry[0]
return measured
targets = ((detection.CandidateFrontEnd, "coarse_scores", "coarse_detection"),
(detection.CandidateFrontEnd, "split_scores", "cheap_lab052"),
(detection.previous.DecimationCache, "get", "shared_decimation"),
(detection.previous.BlockFrontEnd, "extract", "soft_extraction"))
try:
for owner, name, label in targets:
original = getattr(owner, name)
patches.append((owner, name, original))
setattr(owner, name, wrap(original, label))
yield self
finally:
for owner, name, original in reversed(patches):
setattr(owner, name, original)
def detailed_summary(self):
output = self.summary()
for kind in ("valid", "false"):
rows = [row for row in self.rows if row["kind"] == kind]
names = sorted({name for row in rows for name in row["stages"]})
output[kind]["packet_total"] = distribution([row["seconds"] for row in rows])
output[kind]["per_packet_stages"] = {
name: {field: distribution([row["stages"].get(name, {}).get(field, 0.0) for row in rows])
for field in ("seconds", "exclusive_seconds")} for name in names}
return output
def baseline_profile() -> None:
sequence = study.sequence_fixture()
profiler = DetailedProfiler()
started = time.perf_counter()
with profiler.measure():
stream, receiver = study.measured_stream(sequence, detection.StreamingReceiver)
full_experiment_seconds = time.perf_counter() - started
result = {"stream": stream, "profile": profiler.detailed_summary(), "packets": profiler.rows,
"experiment_wall_seconds": full_experiment_seconds,
"source_and_diagnostics_seconds": full_experiment_seconds - stream["processing_seconds"],
"timing_includes_instrumentation": True, "rf_tx": 0}
write_result("baseline_profile.json", result)
print(json.dumps({"profile": result["profile"], "processing": stream["processing_seconds"],
"rx_packets": receiver.packet_count}), flush=True)
def prepare_source(frame_count=30) -> None:
"""Точные блоки прежнего генератора; конечный файл принадлежит источнику.
RX далее читает ограниченные блоки обычным read, без mmap всего файла.
Стоимость синтеза передаваемой формы не объявляется стоимостью live RX.
"""
source_path = SOURCE if frame_count == 30 else SOURCE.with_name(f"source_{frame_count}.c64")
source_path.parent.mkdir(parents=True, exist_ok=True)
sequence = study.sequence_fixture()
sequence = [replace(sequence[number % len(sequence)], image_id=number + 1) for number in range(frame_count)]
checksum = hashlib.sha256()
counts = []
started = time.perf_counter()
with source_path.open("wb") as output:
class Recorder(detection.StreamingReceiver):
def push_iq_block(self, samples):
data = samples.tobytes()
output.write(data)
checksum.update(data)
counts.append(samples.size)
self.total_samples += samples.size
return []
def finish(self):
self.closed = True
return []
original = study.performance.fast
try:
study.performance.fast = SimpleNamespace(StreamingReceiver=Recorder)
study.performance.run_stream(sequence)
finally:
study.performance.fast = original
result = {"path": str(source_path.relative_to(ROOT)), "dtype": "complex64", "blocks": len(counts),
"input_frames": frame_count, "sample_count": sum(counts), "bytes": source_path.stat().st_size, "sha256": checksum.hexdigest(),
"block_samples": 65536, "last_block_samples": counts[-1],
"iq_seconds": sum(counts) / 8000000, "preparation_seconds": time.perf_counter() - started,
"source_fixture": "data/processed/lab052/source_fixture.json", "rf_tx": 0}
write_result("source.json" if frame_count == 30 else f"source_{frame_count}.json", result)
print(json.dumps(result), flush=True)
def optimized_stream(workers=1) -> dict:
from experiments.lab053_streaming_receiver import StreamingReceiver
rows = []
result, receiver = study.measured_stream(study.sequence_fixture(), lambda: StreamingReceiver(
workers=workers, packet_observer=lambda row, outcome: rows.append(row)))
result.update(workers=workers, packet_rows=rows, candidate_queue_peak=receiver.candidate_queue_peak,
candidate_overflow=receiver.candidate_overflow,
speculative_checks=receiver.speculative_checks,
discarded_speculative_checks=receiver.discarded_speculative_checks,
discarded_speculative_seconds=receiver.discarded_speculative_seconds)
valid = [row for row in rows if row["accepted"]]
result["valid_packet_total"] = distribution([row["seconds"] for row in valid])
result["valid_packet_stages"] = {name: distribution([row["exclusive_stages"].get(name, 0) for row in valid])
for name in sorted({name for row in valid for name in row["stages"]})}
write_result(f"initial_workers_{workers}.json", result)
print(json.dumps({key: result[key] for key in ("workers", "processing_seconds", "processing_per_rf_second",
"rx_physical_packets", "received_images", "object_jpeg_integrity", "fresh_display_fps",
"image_age_min_seconds", "image_age_median_seconds", "image_age_max_seconds", "candidate_count",
"valid_packet_total", "candidate_queue_peak", "discarded_speculative_checks")}), flush=True)
return result
def summarize_results() -> None:
"""Выбрать целые медианные проходы и пересчитать критерии из их строк."""
def read(name):
return json.loads((OUTPUT / name).read_text(encoding="utf-8"))
selected = {}
repeats = {}
for mode in ("paced", "throughput"):
runs = [read(f"{mode}_1_repeat_{number}.json") for number in (1, 2, 3)]
chosen = sorted(range(3), key=lambda number: runs[number]["processing_seconds"])[1]
result = dict(runs[chosen])
result["selection"] = {"method": "whole median processing-time run", "repeat": chosen + 1,
"source": f"{mode}_1_repeat_{chosen + 1}.json"}
selected[mode], repeats[mode] = result, runs
write_result("stream.json" if mode == "paced" else "throughput.json", result)
write_result(f"{mode}_workers_1.json", result)
main, throughput = selected["paced"], selected["throughput"]
comparison = [{"workers": number,
"throughput_processing_per_rf_second": read(f"throughput_workers_{number}.json")["processing_per_rf_second"],
**{key: read(f"paced_workers_{number}.json")[key] for key in (
"processing_per_rf_second", "fresh_display_fps", "image_age_max_seconds", "candidate_queue_peak",
"iq_queue_peak", "speculative_checks", "discarded_speculative_checks")}} for number in (1, 2, 4)]
write_result("worker_comparison.json", {"selected_workers": 1, "comparisons": comparison,
"reason": "single worker is sufficient; additional workers give no measured throughput or latency benefit"})
long = read("long_stream.json")
equivalence, sensitivity = read("equivalence.json"), read("sensitivity.json")
regression = read("regression20k/result.json")
source = read("source.json")
overflow_probe = read("overflow_probe.json")
with (ROOT / source["path"]).open("rb") as source_handle:
source_sha256 = hashlib.file_digest(source_handle, "sha256").hexdigest()
cases = repeats["paced"] + repeats["throughput"] + [read(f"{mode}_workers_{worker}.json")
for mode in ("paced", "throughput") for worker in (2, 4)]
checks = {
"all_main_runs_240_packets_30_exact_jpegs": all(row["rx_physical_packets"] == 240 and row["received_images"] == 30
and row["object_jpeg_integrity"] for row in cases),
"all_main_runs_255_strict_15_false": all(row["candidate_count"] == 255 for row in cases),
"all_paced_runs_5_fresh_fps": all(row["fresh_display_fps"] == 5 for row in cases if row["mode"] == "PACED_WALL_CLOCK"),
"all_main_age_at_most_200ms": all(row["image_age_max_seconds"] <= .2 for row in cases if row["mode"] == "PACED_WALL_CLOCK"),
"all_main_processing_ratio_below_08": all(row["processing_per_rf_second"] <= .8 for row in cases),
"all_main_sources_complete_and_no_overflow": all(row["source_consumed_completely"] and not row["errors"] and
row["iq_overflow"] == row["candidate_overflow"] == row["output_overflow"] == 0 for row in cases),
"all_main_queues_within_caps": all(row["iq_queue_peak"] <= row["iq_queue_capacity"] and
row["candidate_queue_peak"] <= row["candidate_queue_capacity"] and row["output_queue_peak"] <= row["output_queue_capacity"] for row in cases),
"same_240_phy_crc_timing_all_worker_counts": equivalence["passed"],
"sensitivity_37_of_45_identical": sensitivity["same_verdicts"] and sensitivity["optimized_correct"] == 37 and sensitivity["lost_baseline_packets"] == 0,
"long_960_packets_120_jpeg_5_fps": long["rx_physical_packets"] == 960 and long["received_images"] == 120 and
long["object_jpeg_integrity"] and long["fresh_display_fps"] == 5 and long["image_age_max_seconds"] <= .2,
"long_queues_within_caps_no_overflow": long["iq_queue_peak"] <= long["iq_queue_capacity"] and
long["candidate_queue_peak"] <= long["candidate_queue_capacity"] and not long["errors"] and
long["iq_overflow"] == long["candidate_overflow"] == long["output_overflow"] == 0,
"long_complete_source_and_cleared_state": long["consumed_iq_seconds"] == long["iq_seconds"] and
long["retained_after_close_bytes"] == long["pending_jobs_after_close"] == 0,
"old20k_36_crc_sha": regression["strict"]["crc_pass"] == 36 and regression["object"]["valid_fragments"] == 36 and
regression["after_independent_verdict"]["sha_match"],
"source_exact_duration": source["sample_count"] == 48355328 and source["iq_seconds"] == 6.044416,
"source_file_size": (ROOT / source["path"]).stat().st_size == source["bytes"],
"source_file_sha256": source_sha256 == source["sha256"],
"forced_overflow_is_reported_and_state_cleared": overflow_probe["iq_overflow"] == 1 and
overflow_probe["iq_queue_peak"] == overflow_probe["iq_queue_capacity"] and
not overflow_probe["source_consumed_completely"] and
any(row["reason"] == "IQ_QUEUE_OVERFLOW" for row in overflow_probe["errors"]) and
overflow_probe["retained_after_close_bytes"] == overflow_probe["pending_jobs_after_close"] == 0,
"protocol_unchanged": not subprocess.check_output(["git", "status", "--porcelain", "--untracked-files=all", "--", "protocol"], cwd=ROOT).strip(),
"lab052_detector_unchanged": not subprocess.check_output(["git", "diff", "--", "experiments/lab052_candidate_detection.py"], cwd=ROOT).strip(),
"lab053_index_empty": not subprocess.check_output(["git", "diff", "--cached", "--name-only"], cwd=ROOT).strip(),
}
fec_crc = sum(main["valid_packet_stages"][name]["sum"] for name in ("viterbi", "protocol_crc"))
partition = {"A_iq_read_and_input_preparation": main["input_statistics"]["read_seconds"] + main["iq_input_preparation_seconds"],
"B_common_decimation": main["shared_front_end_seconds"], "C_coarse_detection": main["coarse_detection_exclusive_seconds"],
"D_cheap_lab052": main["cheap_seconds"], "E_true_packet_without_G": main["true_strict_seconds"] - fec_crc,
"F_false_strict": main["false_strict_seconds"], "G_viterbi_crc": fec_crc,
"H_fragment_assembly": main["assembly_seconds"], "I_jpeg_decode": main["jpeg_decode_seconds"],
"J_freshness": main["output_statistics"]["freshness_seconds"]}
partition["other_orchestration_and_instrumentation"] = main["processing_seconds"] - sum(partition.values())
rss = long["rss_samples"]
late = rss[len(rss)//2:]
# К старой оценке временных массивов добавлены ЯВНЫЕ резервы постоянных кэшей.
ring_samples = main["allocated_iq_ring_bytes"] // 16
maximum_waveform = ring_samples - 2*65536 - 8000
rotation_cache = 16 * maximum_waveform * 8
detection_rotation_cache = 8 * ((ring_samples + 7)//8) * 8
constants_reserve = 1024*1024
memory = {"retained_iq_peak_bytes": main["retained_iq_peak_bytes"], "allocated_ring_bytes": main["allocated_iq_ring_bytes"],
"iq_queue_peak_bytes": main["iq_queue_peak"] * 65536*8, "iq_queue_limit_bytes": main["iq_queue_limit_bytes"],
"temporary_working_buffers_estimate_bytes": main["working_arrays_engineering_estimate_bytes"],
"maximum_frame_waveform_samples": maximum_waveform, "rotation_cache_maxsize": 16,
"maximum_rotation_cache_bytes": rotation_cache, "maximum_detection_rotation_cache_bytes": detection_rotation_cache,
"constant_templates_and_trellis_reserve_bytes": constants_reserve,
"working_arrays_and_constant_caches_estimate_bytes": main["working_arrays_engineering_estimate_bytes"] + rotation_cache + detection_rotation_cache + constants_reserve,
"estimate_is_not_a_universal_c_allocation_bound": True,
"process_memory": main["process_memory"], "long_process_memory": long["process_memory"],
"long_late_rss_min_bytes": min(row["rss_bytes"] for row in late), "long_late_rss_max_bytes": max(row["rss_bytes"] for row in late),
"long_late_rss_slope_bytes_per_second": float(np.polyfit([row["iq_seconds"] for row in late], [row["rss_bytes"] for row in late], 1)[0]),
"receiver_state_bounded": checks["all_main_queues_within_caps"] and checks["long_queues_within_caps_no_overflow"]
and checks["long_complete_source_and_cleared_state"], "experiment_diagnostic_history_is_external": True}
write_result("memory.json", memory)
write_result("acceptance.json", {"checks": checks, "required_checks_pass": all(checks.values()),
"real_time_capable": all(checks.values()), "software_5fps_target_pass": all(checks.values()),
"hardware_validated": False, "head": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"source_code_sha256": {str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted((ROOT/"experiments").glob("lab053_*.py"))},
"paced_service_partition_seconds": partition, "paced_service_processing_per_rf_second": main["processing_per_rf_second"],
"saturated_wall_processing_per_rf_second": throughput["processing_per_rf_second"],
"estimated_single_worker_service_for_40_packets_seconds": 40*main["valid_packet_total"]["mean"],
"budget_note": "perf_counter service durations; Windows thread_time has 15.625ms accounting quantization", "rf_tx": 0})
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
figure, axes = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
for number, row in enumerate(repeats["paced"], 1):
axes[0, 0].plot([image["image_id"] for image in row["images"]],
[1000*image["image_age_seconds"] for image in row["images"]], label=f"Проход {number}")
axes[0, 0].axhline(200, color="red", linestyle="--", label="Предел 200 мс")
axes[0, 0].set(xlabel="Номер изображения", ylabel="Возраст JPEG, мс", title="Подача IQ по часам: 5 свежих кадров/с")
axes[0, 0].legend(fontsize=8)
axes[0, 1].bar(["Lab052", "Lab053\nработа стадий", "Lab053\nбез ожидания"], [1.2192732766011487, main["processing_per_rf_second"], throughput["processing_per_rf_second"]])
axes[0, 1].axhline(1, color="red", linestyle="--")
axes[0, 1].axhline(.8, color="green", linestyle=":")
axes[0, 1].set(ylabel="Время обработки / длительность IQ", title="Пропускная способность")
axes[1, 0].plot([row["capture_timestamp"] for row in long["images"]], [1000*row["image_age_seconds"] for row in long["images"]])
axes[1, 0].axhline(200, color="red", linestyle="--")
axes[1, 0].set(xlabel="Время входной сессии, с", ylabel="Возраст JPEG, мс", title="Длинная сессия: 120 точных JPEG")
axes[1, 1].plot([row["iq_seconds"] for row in rss], [row["rss_bytes"]/2**20 for row in rss], marker=".")
axes[1, 1].set(xlabel="Принятая длительность IQ, с", ylabel="RSS процесса, МиБ", title="Память в длинной сессии")
for axis in axes.flat:
axis.grid(alpha=.2)
figure.savefig(OUTPUT / "performance_and_age.png", dpi=150)
figure.savefig(OUTPUT / "performance_and_age.svg")
plt.close(figure)
svg = OUTPUT / "performance_and_age.svg"
svg.write_text("\n".join(line.rstrip() for line in svg.read_text(encoding="utf-8").splitlines()) + "\n", encoding="utf-8")
print(json.dumps({"checks": checks, "memory": memory, "partition": partition}), flush=True)
def run_functional_tests() -> None:
assert distribution([1, 2, 3])["median"] == 2
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--stage", choices=("profile", "source", "optimized", "report"), default="profile")
parser.add_argument("--workers", type=int, default=1)
parser.add_argument("--frames", type=int, default=30)
arguments = parser.parse_args()
if arguments.stage == "optimized":
optimized_stream(arguments.workers)
elif arguments.stage == "source":
prepare_source(arguments.frames)
elif arguments.stage == "report":
summarize_results()
else:
baseline_profile()
if __name__ == "__main__":
main()