perf(images): run image work on two own pools and trim after jobs (#523) (#582)

* perf(images): read PNG text without decoding pixels (#523)

Pillow's PngImageFile.text loads every pixel to reach text chunks written
after IDAT. The scan's metadata batch and the image monitor now read PNG
text through utils/png_text.py, which runs Pillow's own chunk handlers
after the first IDAT and seeks over the pixel data.

The legacy .text read is the floor: a differential test compares both
readers on every fixture image, a curated corpus and a seeded fuzz. The
only difference is a file whose pixel data is undecodable or cut short:
it now keeps the text read before its pixels instead of reading nothing.
Animated PNGs still use .text.

* test(images): cover the text budget, XMP and layout guard of the PNG text walk (#523)

* perf(images): run image work on two own pools and trim after jobs (#523)

Image work no longer spreads over the default executor's threads, where
glibc kept an image-sized malloc arena per thread.

- Interactive image work (view previews behind their gate, workspace card
  facts, both inspector routes) runs on the API's fixed pm-image pool of
  3 threads; the on-demand preview pool is merged into it.
- map_parallel (scan metadata, thumbnail and convert jobs, the legacy
  gallery generation, capture previews) shares one long-lived pm-worker
  pool of Worker threads, reused between jobs. The package teardown stops
  it within its bound (part "workers").
- After a scan, thumbnail or convert job, glibc's malloc_trim(0) returns
  free pages once; a no-op on Windows, macOS and musl.
- tools/perf/memory_drive.py measures RSS, heap, threads and arenas under
  the #523 load on the disposable browser test server (manual only).

* fix(images): start no image thread after the teardown; join retired job pools (#523)
This commit is contained in:
Vito
2026-11-01 01:24:27 +00:00
committed by GitHub
parent 7a2a04b752
commit 8a81114dfc
19 changed files with 1251 additions and 83 deletions
+10
View File
@@ -85,6 +85,7 @@ try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.native_memory import release_free_memory
from ...utils.png_text import png_text
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
@@ -96,6 +97,7 @@ except ImportError:
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.native_memory import release_free_memory
from utils.png_text import png_text
from utils.video_metadata import is_video_path, read_video_metadata
@@ -528,6 +530,14 @@ class PromptManagerAPI(
return await loop.run_in_executor(None, call)
return await loop.run_in_executor(None, func, *args)
async def _release_job_memory(self):
"""After a scan or thumbnail job: glibc's free heap pages go back to
the OS once (``malloc_trim(0)``; nothing elsewhere, #523)."""
try:
await self._run_in_executor(release_free_memory)
except Exception: # best effort: the job's result already stands
self.logger.debug("Releasing free memory failed", exc_info=True)
def _root_listing(self, root) -> RootListing:
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
+3 -1
View File
@@ -1454,6 +1454,7 @@ class AdminRoutesMixin:
verb = "cancelled" if state == "cancelled" else "finished"
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
job.finish(state)
await self._release_job_memory()
if state == "finished":
# After the scan, so the guarded thumbnail job is admitted (#249).
await self._hand_off_scan_thumbnails(job, output_dirs)
@@ -1668,7 +1669,8 @@ class AdminRoutesMixin:
def _extract_batch_metadata_sync(self, file_batch):
"""Extract metadata from a batch of files in one executor call.
Files are read on ``PromptManagerConfig.WORKER_THREADS`` threads.
Files are read on the long-lived job pool of
``PromptManagerConfig.WORKER_THREADS`` threads (#523).
PNG text is read chunk by chunk without decoding pixels (#523); file
reads and ffprobe release the GIL.
"""
+4 -1
View File
@@ -843,7 +843,10 @@ class HistoryRoutesMixin:
raise
async def execute():
pending = asyncio.create_task(self._run_in_executor(project))
# An inspection reads the file's header and text: the
# interactive image pool (#523); media stays on the default.
run = self._run_image if kind == "inspection" else self._run_in_executor
pending = asyncio.create_task(run(project))
try:
return await asyncio.shield(pending)
except asyncio.CancelledError:
+31 -11
View File
@@ -2975,6 +2975,7 @@ class ImageRoutesMixin:
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs
)
await self._release_job_memory()
return web.json_response(result)
except Exception as e:
@@ -3997,7 +3998,8 @@ class ImageRoutesMixin:
**observed,
)
body = await self._run_in_executor(inspect)
# Header and text reads: the interactive image pool (#523).
body = await self._run_image(inspect)
if body is None:
return web.json_response(
{
@@ -4210,6 +4212,22 @@ class ImageRoutesMixin:
# (which Windows refuses on open files) is not held up by it.
return _CardFacts(width, height, *self._card_parse(path, records))
def _cached_card_facts(self, entry: tuple) -> _CardFacts | None:
path, mtime, root, index = entry[:4]
return self._workspace_card_cache.get((index, str(root)), {}).get((path, mtime))
def _card_facts_of(self, entries: list, *, parse: bool) -> list[_CardFacts]:
"""Card facts of *entries*, in order; files not cached yet are read on
the interactive image pool (#523), cached ones answer here."""
found = [self._cached_card_facts(entry) for entry in entries]
missing = [i for i, facts in enumerate(found) if facts is None]
read = self._map_image(
lambda i: self._card_facts(entries[i], parse=parse), missing
)
for i, facts in zip(missing, read):
found[i] = facts
return found
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
"""One listed image's card facts, read once per ``(path, mtime)``.
@@ -4218,11 +4236,11 @@ class ImageRoutesMixin:
and drops the rest, and a roots change drops them all. Only files of
the current scan are stored, so the cache is bounded by the listing.
"""
path, mtime, root, index = entry[:4]
root_key, key = (index, str(root)), (path, mtime)
cached = self._workspace_card_cache.get(root_key, {}).get(key)
cached = self._cached_card_facts(entry)
if cached is not None:
return cached
path, mtime, root, index = entry[:4]
root_key, key = (index, str(root)), (path, mtime)
facts = self._read_card_facts(path, parse=parse)
if facts is None:
# Not stored: a locked or half-written file may read next time.
@@ -4248,15 +4266,17 @@ class ImageRoutesMixin:
if linked
else {}
)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
stills = [image for image in images if not image["is_video"]]
read = self._card_facts_of(
[entries[(image["root_index"], image["path"])] for image in stills],
parse=linked,
)
facts_of = {id(image): facts for image, facts in zip(stills, read)}
for image in images:
entry = entries[(image["root_index"], image["path"])]
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
facts = (
_NO_CARD_FACTS
if image["is_video"]
else self._card_facts(entry, parse=linked)
)
facts = facts_of.get(id(image), _NO_CARD_FACTS)
image["width"], image["height"] = facts.width, facts.height
if not linked:
continue
+89 -26
View File
@@ -8,7 +8,8 @@ previews behind those URLs are made here, in three ways:
- **On first view.** ``GET /prompt_manager/images/serve/thumbnails/<rel>_thumb
<ext>`` for a preview that does not exist yet is admitted on the event loop
(``_PreviewGate``): at most ``ON_DEMAND_LIMIT`` previews are written at once,
on this API's own small pool (never the default executor), one flight runs
on this API's interactive image pool (``IMAGE_POOL_SIZE`` threads, shared
with card facts and inspector reads; never the default executor), one flight runs
per file, and the requests waiting are capped (``MAX_WAITING``); past the
cap a request gets its fallback at once. Inside the admission the preview is
mapped back to its media file under the same root (the request text is
@@ -24,7 +25,8 @@ previews behind those URLs are made here, in three ways:
- **On capture.** The image monitor hands every new output it settles to
``queue_capture_thumbnail``, which queues its preview for one background
worker thread (``AutoThumbnailer``). Each wake takes at most Worker threads
(``WORKER_THREADS``) files and writes them on that many threads; enqueuing
(``WORKER_THREADS``) files and writes them on the long-lived job pool
(``utils.parallel``, shared with scans and thumbnail jobs); enqueuing
is O(1), so the monitor and ComfyUI's queue never wait. A file already
queued is not queued again, the queue is bounded (``MAX_QUEUED``) and a
preview left out is made on its first view instead.
@@ -49,7 +51,7 @@ write starts (after its slot) and again just before its rename.
Shutdown: ``close_auto_thumbnails`` stops the worker at a file boundary,
kills a running ffmpeg, waits a bounded time (and logs when the wait ran
out) and shuts the view pool down; after the app's cleanup
out) and shuts the image pool down; after the app's cleanup
(``final=True``) nothing starts a worker again. Importing this module again
(a package reload) stops the workers the previous import left.
"""
@@ -60,7 +62,7 @@ import functools
import io
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import CancelledError, ThreadPoolExecutor
from pathlib import Path
from aiohttp import web
@@ -104,8 +106,11 @@ PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_
PLACEHOLDER_QUALITY = 85
# Source extensions in the order a shared preview name looks for them (R2).
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + tuple(sorted(VIDEO_EXTENSIONS))
# Previews views may write at once (the size of the view pool).
ON_DEMAND_LIMIT = 3
# Threads of the interactive image pool (#523): view previews, card facts and
# inspector reads. Fixed, so image buffers stay in a few threads' arenas.
IMAGE_POOL_SIZE = 3
# Previews views may write at once (the whole image pool).
ON_DEMAND_LIMIT = IMAGE_POOL_SIZE
# View requests that may wait on the gate; one more gets its fallback at once.
MAX_WAITING = 64
# How long a pool thread waits for the capture worker's flight of the same file.
@@ -124,7 +129,7 @@ AUTO_REFUSED_KINDS = frozenset(
{RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND}
)
WORKER_NAME = "PromptManagerThumbnailWorker"
POOL_PREFIX = "pm-preview"
POOL_PREFIX = "pm-image"
# Identifies this import's workers: a reload stops the ones it did not start.
_MODULE_TOKEN = object()
@@ -294,7 +299,7 @@ class AutoThumbnailer:
``generate(src, dst, is_video, *, stop, guard, track)`` writes one
preview and returns ``_generate_one``'s result dict; ``may_write()``
says whether automatic previews may be written now. Views call
``ensure`` on the view pool, the monitor calls ``enqueue`` from its
``ensure`` on the image pool, the monitor calls ``enqueue`` from its
thread.
"""
@@ -491,12 +496,15 @@ class AutoThumbnailer:
self._worker_running = False
self._changed.notify_all()
return
take = min(len(self._queue), max(1, int(self._workers() or 1)))
workers = max(1, int(self._workers() or 1))
take = min(len(self._queue), workers)
batch = [self._queue.popleft() for _ in range(take)]
self._batch = len(batch)
self._changed.notify_all()
try:
map_parallel(self._write_queued, batch, len(batch))
# The job pool's size is the setting; a smaller batch uses
# fewer of its threads (#523: one long-lived pool).
map_parallel(self._write_queued, batch, workers)
finally:
with self._changed:
self._batch = 0
@@ -567,7 +575,7 @@ class _PreviewGate:
"""Admission of view requests on the event loop (#249 I2).
Waiters are coroutines: at most ``limit`` previews are written at once
on the view pool, one flight runs per key, and past ``MAX_WAITING``
on the image pool, one flight runs per key, and past ``MAX_WAITING``
waiting requests ``run`` answers ``OVER`` at once.
"""
@@ -622,13 +630,17 @@ class _PreviewGate:
flight.set_result(result)
def _on_image_thread():
return threading.current_thread().name.startswith(POOL_PREFIX + "_")
class AutoThumbnailMixin:
"""Automatic previews on the API: views, the monitor's queue, scans."""
_auto_thumbnailer = None
_auto_thumbnails_shut = False
_preview_gate_obj = None
_preview_pool_obj = None
_image_pool_obj = None
_auto_thumbnailer_lock = threading.Lock()
def _auto_thumbnails(self):
@@ -651,7 +663,7 @@ class AutoThumbnailMixin:
self._auto_thumbnails_shut = True
def close_auto_thumbnails(self, final=False, timeout=None):
"""Stop the capture worker and the view pool, and forget them.
"""Stop the capture worker and the image pool, and forget them.
Bounded by ``timeout`` (see AutoThumbnailer.close). ``final`` (the
package teardown) also keeps a late monitor event from starting a
@@ -662,7 +674,7 @@ class AutoThumbnailMixin:
if final:
self._auto_thumbnails_shut = True
thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None
pool, self._preview_pool_obj = self._preview_pool_obj, None
pool, self._image_pool_obj = self._image_pool_obj, None
self._preview_gate_obj = None
closed = True
if thumbnailer is not None:
@@ -706,16 +718,67 @@ class AutoThumbnailMixin:
gate = self._preview_gate_obj = _PreviewGate(ON_DEMAND_LIMIT, self.logger)
return gate
def _preview_pool(self):
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._preview_pool_obj is None:
self._preview_pool_obj = ThreadPoolExecutor(
ON_DEMAND_LIMIT, thread_name_prefix=POOL_PREFIX
)
return self._preview_pool_obj
def _image_pool(self):
"""The interactive image pool (#523): IMAGE_POOL_SIZE threads for view
previews (behind their gate), card facts and inspector reads, so
image buffers never spread over the default executor's threads.
def _preview_pool_threads(self):
pool = self._preview_pool_obj
None after the package teardown (``close_auto_thumbnails(final=True)``):
a late request then runs on the default executor (``run_in_executor``
with None) and starts no thread the teardown no longer stops.
"""
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._image_pool_obj is None and not self._auto_thumbnails_shut:
self._image_pool_obj = ThreadPoolExecutor(
IMAGE_POOL_SIZE, thread_name_prefix=POOL_PREFIX
)
return self._image_pool_obj
async def _run_image(self, func, *args, **kwargs):
"""``_run_in_executor`` for interactive image work: the image pool.
After the teardown, or with the pool shut down in between (a test
reset), the call runs on the default executor as before.
"""
call = functools.partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
try:
future = loop.run_in_executor(self._image_pool(), call)
except RuntimeError: # submit refused: the pool was shut down
future = loop.run_in_executor(None, call)
return await future
def _map_image(self, func, items):
"""``func`` over *items* on the image pool, in order (blocking).
Called from an image pool thread, after the teardown, or with the
pool shut down in between (its queued items are cancelled), the
items it did not run run on the calling thread.
"""
items = list(items)
pool = None if not items or _on_image_thread() else self._image_pool()
if pool is None:
return [func(item) for item in items]
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError:
pass # shut down in between: the rest run here
results = []
for index, item in enumerate(items):
future = futures[index] if index < len(futures) else None
if future is None or future.cancelled():
results.append(func(item))
continue
try:
results.append(future.result())
except CancelledError: # cancelled while this caller waited
results.append(func(item))
return results
def _image_pool_threads(self):
pool = self._image_pool_obj
return [] if pool is None else list(getattr(pool, "_threads", ()))
def _video_placeholder_bytes(self):
@@ -730,7 +793,7 @@ class AutoThumbnailMixin:
return await self._preview_gate().run(
flight_key(Path(dst).parent, (Path(dst).name,)),
functools.partial(self._ensure_view, src, dst, is_video),
self._preview_pool(),
self._image_pool(),
)
def _ensure_view(self, src, dst, is_video):
@@ -774,13 +837,13 @@ class AutoThumbnailMixin:
The flight answers only the outcome (a history flight may be the one
joined); the file it previews is derived here: the preview path is
the request's own, and a fallback looks its source up again on the
view pool (bounded by it, never the default executor).
image pool (bounded by it, never the default executor).
"""
if _has_traversal(filepath) or not _is_preview_request(filepath):
return None
parts = tuple(filepath.split("/"))
gate = self._preview_gate()
pool = self._preview_pool()
pool = self._image_pool()
for root in output_dirs:
root = Path(root)
outcome = await gate.run(
+1
View File
@@ -153,6 +153,7 @@ class ThumbnailConvertMixin:
job.log_line("Conversion failed · internal error")
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _convert_job_body(self, job, options):
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
+1
View File
@@ -226,6 +226,7 @@ class ThumbnailJobsMixin:
)
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _thumbnail_job_body(self, job, options):
"""Plan, then generate in worker batches; returns the final state.