Spaces:
Running
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 satisfydire_detector.py/clip_detector.py's type annotations at import time;dire_detector/clip_detector/own_detectorswapped for instant fakes so timing measures only the 9-signal pool this change touches) β 3 repeated runs produced identicalai_probabilityand identical 30-signal sets, confirming no race condition/data corruption from the thread pool. - Directly verified the
max_workerscapping 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 asNone, whichThreadPoolExecutorwould otherwise interpret as "pick a default based on core count," silently reintroducing the oversubscription bug this fix exists to prevent). - Full
backend/stillpy_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")