Skip to content

Size the asyncio filesystem's parallel requests by max_workers instead of the default executor #1159

Closed

Description

@laughingman7743

Summary

S3AioExecutor and AioS3FileSystem._copy_object_with_multipart_upload() run blocking requests with asyncio.to_thread(). As a result, their parallelism is capped by the event loop's default executor, min(32, os.cpu_count() + 4) threads, whatever max_workers is.

Evidence

The filesystem suite of the 4.1.0 benchmark (benchmarks/history/2026-10-13-437276e1-filesystem.md, #1157, #685) ran on a c8gn.2xlarge host (8 vCPUs, so 12 threads in the default executor). With max_workers=40:

Operation sync (MiB/s) aio (MiB/s) aio / sync
write, 1 GiB 712–718 575–593 0.81–0.83
write, 6 GiB 861–864 647–665 0.75–0.77
multipart copy, 6 GiB 1,495–1,781 521–540 0.30–0.35

The sync processes peaked at 51 threads and the aio processes at 24–25. With max_workers=8, aio matched or exceeded sync. Reads were alike.

  • AioS3File dispatches parts through S3AioExecutor to fsspec's IO loop.
  • The copy coroutine uses asyncio.to_thread() on the caller's loop.
  • Neither overrides the default executor, and setting one is up to the application.

Proposal

Give the asyncio paths a thread pool sized by max_workers. For example, S3AioExecutor and the copy could use loop.run_in_executor() with a ThreadPoolExecutor(max_workers) owned by the file or the filesystem call, instead of the loop's default executor. Document the behavior if the cap is kept.

Re-measurement and the async default

#685 left one decision to this issue: whether writes and copies should default to AioS3FileSystem. After the change, re-run the filesystem suite and decide.

  • Setup: the same selection on c8gn.2xlarge, as in the README's commands for that host: reads and writes up to large, writes and copies at xlarge, max_workers 8 and 40, pools 10 and 40, five repetitions.
  • Comparison: compare with benchmarks/history/2026-10-13-437276e1-filesystem.md, and record the run as a new history entry.
  • Done when:

Activity

  1. laughingman7743 commented on Oct 14, 2026

    @laughingman7743
    MemberAuthor

    Plan: the fix for this issue does not run the benchmark on its own. The re-measurement described here happens in a single combined benchmark run on the commit of the release that contains #1158, #1159, #1160, and #1161 (planned as 4.2.0), together with #1162. The run uses one r7g.2xlarge stack for the cursor and read_sql_query() cases and one c8gn.2xlarge stack for the filesystem suite, on the same day.

  2. laughingman7743 commented on Oct 14, 2026

    @laughingman7743
    MemberAuthor

    A note from #1173 (PR #1176) for the redesign of the executors here.

    The future of a part that fails through S3Executor.submit_to() is in a reference cycle: the run() closure holds the future, the future holds the exception, and the exception's traceback holds the frame of run(). The frames below run() also hold the part's body. So the body of a failed part is freed by the garbage collector, not when the error is raised or the future is dropped. Measured on 3a50182: a future failed through S3ThreadPoolExecutor.submit_to() stays alive after all references to it are dropped, until gc.collect(). With the reproduction from #1173 (max_workers=4, 8 MiB parts, every part failing), about 32 MiB stay allocated before gc.collect() and 0.2 MiB after.

    #1176 bounds this to at most max_workers failed parts by stopping the write at the wait. Releasing them on the raise is option 2 of #1173: in submit_to(), drop the references of run() to the callable and its arguments, and clear the traceback frames below run(), before set_exception(). If the executors are reworked here, that could be included, or left out deliberately.

  3. laughingman7743 commented on Oct 18, 2026

    @laughingman7743
    MemberAuthor

    Plan update: the combined benchmark is now tracked in #1288, with #1287 and #1162. It measures the 4.2.0 release candidate on master before the release, and 4.2.0 is tagged on the measured commit after the results are reviewed. Before, the plan was to measure the tagged release.

  4. laughingman7743 commented on Oct 19, 2026

    @laughingman7743
    MemberAuthor

    Re-measured in the combined 4.2.0 run (#1288), with the same selection on one c8gn.2xlarge host, recorded in #1310 (benchmarks/history/2026-10-19-9e99caa8-filesystem.md).

    Operation, 40 workers aio / sync, 4.1.0 aio / sync, 4.2.0
    write, 1 GiB 0.81–0.83 0.97–0.99
    write, 6 GiB 0.75–0.77 0.98–1.00
    multipart copy, 6 GiB 0.30–0.35 1.01–1.05 (1,889–1,925 MiB/s)
    • Done-when, 40 workers: aio writes and copies of 1 GiB and 6 GiB reach the sync throughput. The aio processes peaked at 52–53 threads, against 24–25 in 4.1.0 and 51 for sync, so their thread counts follow max_workers.
    • Reads and 8 workers: for objects of 256 MiB and larger, aio reads and 8-worker writes ran at 0.95–1.06 times sync, and 8-worker copies at 1.36–1.49 times.
    • Exceptions against 4.1.0, small objects only:
      • The 8-worker aio writes of 256 MiB ran at 329 and 327 MiB/s, against 329 and 346 MiB/s (0.95 times sync instead of 1.08–1.09).
      • The 64 MiB aio reads were slower in three of four settings, by 17–26%. The 64 MiB transfers had no consistent order among the settings in either run.
    • Also: writes no longer hold the whole object in memory (Bound the parts in flight of S3File multipart writes #1165). A 6 GiB write peaked at 690–699 MiB with 40 workers, against 6,005–6,313 MiB.

    Decision on the default (#685):

    • S3FileSystem stays the filesystem that PyAthena registers for fsspec's s3 and s3a protocols.
    • AioS3FileSystem is now equivalent for writes and copies and is recommended for asyncio applications.
    • Making it the default is to be revisited when boto3 supports asyncio natively, because AioS3FileSystem still runs the blocking boto3 calls in threads.

    Closed by #1310.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions