verifile-x-api / PROFILING_F17.md
abinazebinoy's picture
test(dire): regression test for scheduler isolation + doc update
cfffa10
|
Raw
History Blame Contribute Delete
8.44 kB

F-17 Profiling β€” Parallelizing the 30 Signals

Method

27 of the 30 signals (19-signal statistical bundle + 8 classical forensic detectors + image_type_classifier) are pure numpy/opencv/scipy/scikit-image β€” no torch dependency β€” so they were timed for real: a synthetic 1600Γ—1200 JPEG (smooth gradient + sensor-like noise, quality 88), 5 runs each, mean/min/max recorded. The 3 torch-dependent signals (DIRE, CLIP, own-embedding) couldn't be executed in the profiling session's sandbox (no working torch install there) β€” their relative cost was estimated by reading the model architecture directly instead.

Real measured numbers (mean of 5 runs, synthetic 1600Γ—1200 JPEG)

Signal Mean Notes
Statistical bundle (19 signals, 1 call) 2116.5ms Internally: BasicSignals 1396.7ms, UltraSignals 264.5ms, AdvancedSignals 232.2ms, CovarianceSignals 149.8ms
JPEG ghost 1026.1ms
DCT frequency 758.6ms
Noiseprint 557.7ms
Noise map 481.6ms
CFA 154.8ms
Image type classifier 126.4ms Gates prnu/ela/metadata β€” must resolve before those three, independent of everything else
PRNU 92.0ms
ELA 75.2ms
Metadata 0.1ms

Sum if sequential: ~5.4s. Slowest single signal (the parallel floor for this group): ~2.1s (statistical bundle).

Within the statistical bundle itself, F-16 already decomposed it into 4 independent components sharing only a read-only ImageContext β€” BasicSignals alone is ~66% of the bundle's own total (1.4s of 2.1s), so there's a second, smaller layer of parallelization available inside the bundle if the outer-level win alone isn't enough later. Not implemented in this pass β€” flagged for whoever picks up further F-17 work.

DIRE / CLIP / own-embedding β€” structural estimate, not measured

Read directly from dire_detector.py, clip_detector.py, own_embedding_detector.py:

  • DIRE: loads a full Stable Diffusion 2.1 pipeline, runs 20 sequential UNet forward passes (~860M params) at 512Γ—512, plus a VAE encode/decode. Almost certainly the dominant cost of the whole pipeline on CPU β€” likely an order of magnitude past the 2.1s statistical bundle.
  • CLIP: one forward pass, ViT-B/32 (~88M params). Fast relative to DIRE.
  • own-embedding: one forward pass, EfficientNet-B0 (~5M params). Fastest of the three.

To get a real DIRE number, run this in an environment with working torch (this repo's own dev environment, not necessarily the profiling sandbox):

import time, statistics
from backend.services.dire_detector import DIREDetector

with open("some_real_test_image.jpg", "rb") as f:
    image_bytes = f.read()

times = []
d = DIREDetector()
d.detect(image_bytes, "warmup.jpg")  # first call also pays model-load cost β€” exclude it
for _ in range(3):
    t0 = time.perf_counter()
    d.detect(image_bytes, "test.jpg")
    times.append(time.perf_counter() - t0)
print(f"DIRE mean: {statistics.mean(times):.2f}s  (min {min(times):.2f}s, max {max(times):.2f}s)")

The scheduler-sharing risk β€” RESOLVED

DIRE's DDIMScheduler was loaded once and cached process-wide (ModelCache, F-7) β€” cached_model['scheduler'] β€” with every DIREDetector instance sharing that same object. Both _add_noise() and the denoising loop call mutating methods on it (scheduler.add_noise(), scheduler.set_timesteps(), scheduler.step()). denoise_steps is hardcoded to 20, so a set_timesteps(20) race between two concurrent DIRE calls likely produced the same values either way β€” probably not silently wrong in practice. But that was incidental, not a guarantee, and it was exactly the risk the original F-17 planning flagged as "a plausible interaction risk nobody has tested yet."

Fixed (follow-up commit, after this document was first written): DIREDetector._load_model() now clones a fresh scheduler from the cached one's config on every cache hit (type(cached_scheduler).from_config(cached_scheduler.config)) instead of sharing the cached object directly. from_config() does no disk/network I/O β€” just Python object construction from a config dict β€” so this is negligible overhead per request. self.pipe (the actual UNet/VAE weights) stays shared: frozen, eval-mode forward passes don't mutate shared state, so sharing that part remains safe. Verified directly: two DIREDetector instances that both hit the cache now get two distinct scheduler objects (first.scheduler is not second.scheduler); confirmed this is a real fix, not just plausible, by reverting it and watching the new regression test fail with exactly the shared-object assertion error, then pass again once restored.

This does not by itself mean DIRE should join the parallel signal pool. The remaining open question is the one this document's "DIRE / CLIP / own-embedding β€” structural estimate, not measured" section above already raised: there's still no real DIRE timing number, so it's not known whether DIRE dominates total latency so heavily that parallelizing it alongside anything else provides only a marginal win, or whether there's a genuine, substantial gain available. Get a real number (the script above) before deciding whether extending the thread pool to DIRE/CLIP/ own-embedding is worth the added complexity β€” the thread-safety blocker is gone, but the latency-value question isn't answered yet.

What was actually shipped this round (partial F-17)

The 9 mutually-independent, non-torch signal calls β€” the statistical bundle + 8 classical forensic detectors β€” now run in a ThreadPoolExecutor inside AdvancedEnsembleDetector.detect(), instead of one after another. Every module involved was grepped for module-level caches/globals before this change; none exist (all are pure functions over their own local data), so there's no DIRE-scheduler-style shared-state risk in this half of the change.

A real trap found and fixed during verification: the profiling/development sandbox turned out to have exactly 1 CPU core. On 1 core, 9 threads contending for that core measured ~1.5x SLOWER than plain sequential β€” pure context-switch overhead, zero real parallelism, not a hypothetical concern. Fixed by capping max_workers = min(len(_signal_tasks), max(1, os.cpu_count() or 1)), so the pool degrades to one-thread-at-a-time (matching sequential performance) on a constrained host instead of regressing, and scales up to real concurrency on whatever's actually available.

The project's actual production target (Hugging Face Spaces CPU Basic) is confirmed 2 vCPU β€” real, though bounded (not the full 9-way ideal), benefit is expected there. DIRE/CLIP/own-embedding remain sequential, tracked as a separate follow-up gated on the scheduler-sharing question above and a real DIRE number from the script in this doc.

Verification performed

  • Ran the real, patched AdvancedEnsembleDetector.detect() (torch import stubbed just enough to satisfy dire_detector.py/clip_detector.py's type annotations at import time; dire_detector/ clip_detector/own_detector swapped for instant fakes so timing measures only the 9-signal pool this change touches) β€” 3 repeated runs produced identical ai_probability and identical 30-signal sets, confirming no race condition/data corruption from the thread pool.
  • Directly verified the max_workers capping logic against 3 scenarios: cpu_count()=1 β†’ 1 worker, cpu_count()=64 β†’ 9 workers (capped at task count, not core count), cpu_count()=None β†’ 1 worker (not passed through as None, which ThreadPoolExecutor would otherwise interpret as "pick a default based on core count," silently reintroducing the oversubscription bug this fix exists to prevent).
  • Full backend/ still py_compile-clean after the change.
  • Could not measure real multi-core speedup in the profiling sandbox (1 core, by definition can't demonstrate parallelism). Recommend running the timing harness below on this repo's own environment (the 2-vCPU HF Space, or local dev hardware) to confirm the real-world win:
import time
from backend.services.advanced_ensemble_detector import AdvancedEnsembleDetector

with open("some_real_test_image.jpg", "rb") as f:
    image_bytes = f.read()

t0 = time.perf_counter()
AdvancedEnsembleDetector(image_bytes, "test.jpg").detect()
print(f"Full detect(): {time.perf_counter() - t0:.2f}s")