Lab053: achieve real-time 5 fps software video receiver
This commit is contained in:
333
experiments/lab053_receiver_profile.py
Normal file
333
experiments/lab053_receiver_profile.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user