Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update the binary index before attempting direct fetches #32137

Merged
merged 3 commits into from Oct 19, 2022

Conversation

blue42u
Copy link
Contributor

@blue42u blue42u commented Aug 14, 2022

spack install will not update the binary index if given a concrete spec, which causes it to fall back to direct fetches when a simple index update would have helped. For S3 buckets in particular, this significantly and needlessly slows down the install process.

This commit alters the logic so that the binary index is updated whenever a by-hash lookup fails. The lookup is attempted again with the updated index before falling back to direct fetches.


With 3 nonexistent mirrors + the Spack public binary cache, before the commit:

$ spack mirror list
spack-public    https://mirror.spack.io
public          s3://spack-binaries/develop
dud1            s3://spack-binaries/nonexistent1
dud2            s3://spack-binaries/nonexistent2
dud3            s3://spack-binaries/nonexistent3
$ \time spack install -f zlib.json
==> Installing zlib-1.2.12-m4fqokrwbprfci4rptvgevnklpdo3dhd
gpg: Signature made Sun 14 Aug 2022 12:32:43 PM UTC
gpg:                using RSA key D2C7EB3F2B05FA86590D293C04001B2E3DB0C723
gpg: Good signature from "Spack Project Official Binaries <maintainers@spack.io>" [ultimate]
==> Extracting zlib-1.2.12-m4fqokrwbprfci4rptvgevnklpdo3dhd from binary cache
[+] /tmp/spackinstall/linux-ubuntu18.04-x86_64/gcc-7.5.0/zlib-1.2.12-m4fqokrwbprfci4rptvgevnklpdo3dhd
1.55user 0.18system 0:50.60elapsed 3%CPU (0avgtext+0avgdata 108108maxresident)k
0inputs+3328outputs (0major+70477minor)pagefaults 0swaps

After this commit:

$ \time spack install -f zlib.json
==> Installing zlib-1.2.12-m4fqokrwbprfci4rptvgevnklpdo3dhd
gpg: Signature made Sun 14 Aug 2022 12:32:43 PM UTC
gpg:                using RSA key D2C7EB3F2B05FA86590D293C04001B2E3DB0C723
gpg: Good signature from "Spack Project Official Binaries <maintainers@spack.io>" [ultimate]
==> Extracting zlib-1.2.12-m4fqokrwbprfci4rptvgevnklpdo3dhd from binary cache
[+] /tmp/spackinstall/linux-ubuntu18.04-x86_64/gcc-7.5.0/zlib-1.2.12-m4fqokrwbprfci4rptvgevnklpdo3dhd
12.92user 0.74system 0:38.37elapsed 35%CPU (0avgtext+0avgdata 611908maxresident)k
0inputs+176624outputs (0major+329861minor)pagefaults 0swaps

@spackbot-app spackbot-app bot added binary-packages core PR affects Spack core functionality labels Aug 14, 2022
@blue42u blue42u force-pushed the buildcache-always-index branch 3 times, most recently from bab9286 to d6e740c Compare August 23, 2022 12:17
@scheibelp scheibelp self-assigned this Aug 24, 2022
@scheibelp
Copy link
Member

This looks like it would trigger a large number of attempts at updating the cache (especially if many packages in a DAG aren't kept there), which could itself be expensive. Presumably if you've checked "recently" (e.g. in the last 10 minutes) then there shouldn't be a need to re-check. Do you think it would be reasonable to add a mechanism to limit this?

@blue42u
Copy link
Contributor Author

blue42u commented Aug 25, 2022

Good point, it's only the cost of fetching the index.json.hash but that could build up.

I think a rate limiting mechanism is reasonable, but I'm a little concerned a time-based mechanism would run into race conditions in tight CI pipelines. Would a configurable time-based limit (e.g. one fetch per 10 min) plus a limit of one fetch per BinaryIndex object (i.e. once per spack command) work?

I should have time to implement this over the weekend.

@scheibelp
Copy link
Member

Good point, it's only the cost of fetching the index.json.hash but that could build up.

Ah that's true, I should definitely think more on that before asking: it's not much data (I forgot there's a hash for the index and thought it would have to constantly re-retrieve it) and it's just one request per mirror per package install (which it would be doing anyway if the package were there). I'll think on it for a day.

@scheibelp
Copy link
Member

Follow up: I think there should be a timer on retries but it does not have to persist between Spack invocations (i.e. it can be a property of BinaryCacheIndex)

However, looking through binary_distribution.py, I'm seeing some conceptual overlap between this and BinaryCacheQuery. I'm thinking it would be more consistent to refine the interface of BinaryCacheQuery to allow querying single specs vs. embedding the refresh inside a getter method. Do you have an opinion on that?

@blue42u
Copy link
Contributor Author

blue42u commented Aug 27, 2022

I think there should be a timer on retries but it does not have to persist between Spack invocations (i.e. it can be a property of BinaryCacheIndex)

👍 but why have the timer then? I would think any reasonable timer would be far longer than most Spack commands use of the BinaryCacheIndex?

Do you have an opinion on that?

I'm the wrong person to ask for an opinion, I'm just hacking on the code to make it do what I want. 😅

But now that you made me look... I'm not sure where BinaryCacheQuery fits in. It's only used in two places, in bootstrap.py it could be replaced by binary_index.find_by_hash, and in cmd/buildcache.py it's used to interpret the spec arguments to spack buildcache install (which I now realize is nothing like spack install --cache-only, very confusing).

IMHO, if you want "conceptual consistency," I think the functions in binary_distribution.py (except for the ones used for installing) should be methods on the BinaryCacheIndex singleton since they primarily query the buildcache. The functions used for installing should be methods on a new (maybe CachedBinary?) object returned by BinaryCacheIndex.find_built_spec (and other query methods). But that's a refactor outside the scope of this PR, and I'm not confident enough in my understanding of this code to write that over a weekend.

So I guess my opinion would be that embedding the refresh in a getter method makes the most sense for this PR. (It's also a very common pattern for lazily-filled proxy objects, BinaryCacheIndex seems to be one to me).

@spackbot-app spackbot-app bot added defaults tests General test capability(ies) labels Aug 27, 2022
@blue42u
Copy link
Contributor Author

blue42u commented Aug 28, 2022

Updated with a configurable TTL for the in-memory cache of binary indices, default 10 minutes.

@scheibelp
Copy link
Member

I think there should be a timer on retries but it does not have to persist between Spack invocations (i.e. it can be a property of BinaryCacheIndex)

but why have the timer then? I would think any reasonable timer would be far longer than most Spack commands use of the BinaryCacheIndex?

When running spack install, Spack will attempt to fetch and install potentially many packages (and each of those packages will take anywhere from a few seconds to multiple hours to install): I want the individual package installations that occur within a single instance of spack install to have no guarantee of "freshness", but I do want to allow the user to control it by re-invoking the command: for example if the user manually updates the cache and then reruns the command, I want Spack to fetch those (very) new packages from the binary cache.

@blue42u
Copy link
Contributor Author

blue42u commented Aug 31, 2022

@scheibelp I agree, but let me confirm the exact behavior you're looking for. I am thinking of two possibilities that fit so far:

  1. The first package install in every spack install refreshes the index. After that, the index is not refreshed unless the spack install command takes longer than 10 minutes, in which case the next individual package install will refresh the index again before continuing. Fetches continue to occur at roughly 10-minute intervals until the command completes.
  2. The first package install in every spack install refreshes the index. After that, the index is never refreshed again before the command completes.

Both cases give the freshness guarantee that every spack install will refresh the index at least once. So, which behavior are you looking for? This PR currently implements (1) but (2) is a fairly trivial adjustment away, I'm fine implementing either.

@scottwittenburg
Copy link
Contributor

I just want to chime in on the original intent with binary index caching vs direct fetching, since I had something to do with it. In spack, there's no requirement that a binary index is up to date, or even exists. Or at least that was true when I was implementing the first iteration of the binary cache index. Also, for a spack installation with a single mirror, it was really fast to install a particular spec from binary by using the direct fetch approach, which I wanted to preserve. Requiring fetching/incorporating the index first seemed to slow that down quite a lot at the time.

I think assuming an up to date index is getting more common, and maybe it will eventually be a requirement/invariant, but I think that's some ways off in the future. If that were to happen though, I could imagine dispensing with the "direct fetching" altogether. But at the moment, in our gitlab ci pipelines (where each job may install a lot of binary dependencies), we don't actually update the index at the end of the job, due to the high likelihood of many parallel jobs attempting to update it at the same time. So in that situation, fetching the index at the start of every install is unlikely to prevent the need to fall back to the direct fetch approach, and fetching the index will just slow the pipeline down more.

It's possible I'm missing something or have forgotten a key detail or two, please feel free to point that out. Also, I'm intentionally not approving or requesting changes at this point, just trying to involve myself in the conversation.

I do want to say thanks to @blue42u though, it's great to have someone actively thinking about and contributing to the pipeline effort! 🚀 🙏 Please keep stirring up the waters!

@blue42u
Copy link
Contributor Author

blue42u commented Aug 31, 2022

Also, for a spack installation with a single mirror, it was really fast to install a particular spec from binary by using the direct fetch approach, which I wanted to preserve. Requiring fetching/incorporating the index first seemed to slow that down quite a lot at the time.

I've seen that slowdown myself too with just spack spec zlib, it takes 1 second without any mirrors but almost a minute with the Spack public buildcache enabled. In my experience the slowdown from direct fetching is much worse, AWS takes ~3-5 seconds to report a 4xx error (KeyNotFound), multiplied by 3 specfile extensions and the number of (direct and indirect) dependencies and we're already up to ~10 mins per extra mirror for a spec on the smaller end (50 packages). IMHO trading the latter for the former is reasonable even in the single-mirror case, it's a constant slowdown instead of a potentially massive slowdown that could be triggered by any minor misconfiguration. (Also IIRC the Spack PR pipelines always use multiple mirrors, but don't quote me on that.)

When I looked into it (weeks ago now) it seemed that most of this slowdown was related to "lifting" the entire JSON to spack.spec.Spec objects, the JSON parsing itself took less than a second. I believe this could be significantly optimized by lazily lifting the JSON (node-dict) to spack.spec.Spec on-demand and supporting some lookups without triggering a lift (e.g. check if a hash is present, or search by package name). Problem is that kind of refactor would affect the spack.database bits too, and I'm not quite as keen on hacking willy-nilly in there.

Copy link
Member

@scheibelp scheibelp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have one request for schema documentation and one request to make the update cooldown behavior optional. Let me know if either is confusing or seems wrong.

etc/spack/defaults/config.yaml Show resolved Hide resolved
lib/spack/spack/binary_distribution.py Show resolved Hide resolved
blue42u and others added 3 commits October 18, 2022 18:16
Co-authored-by: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com>
To allow for index re-fetches in sufficiently long-running Spack commands
@blue42u blue42u deleted the buildcache-always-index branch October 22, 2022 13:15
@scottwittenburg
Copy link
Contributor

I just noticed when trying to reproduce a pipeline generation job with spack -d ci generate ... that there's some new output. I'm seeing hundreds of these messages in a row in the debug output:

==> [2022-10-24-22:43:43.153166] Failure reading URL: Download failed: HTTP Error 404: Not Found
==> [2022-10-24-22:43:43.155996] Checking existence of https://mirror.spack.io/build_cache/index.json

I wonder if it's because the default value of the with_cooldown parameter to the update() method is False. To avoid this, do I need to pass with_cooldown=True here now?

Partial log showing the behavior attached below.

checking_existence.txt

@scheibelp
Copy link
Member

scheibelp commented Oct 25, 2022

@scottwittenburg is the CI invoking separate spack install commands? If it is, you could see this message at least once per install command.

With this PR, you should not see more messages of that form (especially many more) unless running spack install multiple times.

As it is, this PR:

  • Doesn't modify any existing call to BinaryCacheIndex.update (they are all called in the same way for the same reasons as before this PR, because the default of with_cooldown=False)
  • Adds one new instance of BinaryCacheIndex.update, and in that case changes its behavior (sets with_cooldown=True)

If in this context a single spack install command is generating many of these messages, I definitely overlooked something in this PR.

@scottwittenburg
Copy link
Contributor

Well, it's not spack install invoking it in this case, spack ci generate has always called BinaryCacheIndex.update in the process of deciding which concrete specs to schedule jobs for. So does that call site just need to pass with_cooldown=True?

@blue42u
Copy link
Contributor Author

blue42u commented Oct 25, 2022

Looking back over this, I think the cooldown isn't activating in the case of a failed fetch, which causes errors like when when find_by_hash fails to find a hash in the current index. Completely my fault, whoops.

I'll whip up a fix quick, one moment...

@scottwittenburg
Copy link
Contributor

So am I wrong that this call site needs updated to pass with_cooldown=True?

@blue42u
Copy link
Contributor Author

blue42u commented Oct 25, 2022

So am I wrong that this call site needs updated to pass with_cooldown=True?

I think so, as the first call to update in the invocation with_cooldown=True is a no-op. IMHO you could probably even remove the call completely, find_by_hash will lazily call update when needed.

But please feel free to test what I think is the fix: #33509

scheibelp pushed a commit that referenced this pull request Oct 25, 2022
#32137 added an option to update() a BinaryCacheIndex with a
cooldown: repeated attempts within this cooldown would not
actually retry. However, the cooldown was not properly
tracked for failures (which is common when the mirror
does not store any binaries and therefore has no index.json).

This commit ensures that update(..., with_cooldown=True) will
also skip the update even if a failure has occurred within the
cooldown period.
becker33 pushed a commit to RikkiButler20/spack that referenced this pull request Nov 2, 2022
spack#32137 added an option to update() a BinaryCacheIndex with a
cooldown: repeated attempts within this cooldown would not
actually retry. However, the cooldown was not properly
tracked for failures (which is common when the mirror
does not store any binaries and therefore has no index.json).

This commit ensures that update(..., with_cooldown=True) will
also skip the update even if a failure has occurred within the
cooldown period.
charmoniumQ pushed a commit to charmoniumQ/spack that referenced this pull request Nov 19, 2022
spack#32137 added an option to update() a BinaryCacheIndex with a
cooldown: repeated attempts within this cooldown would not
actually retry. However, the cooldown was not properly
tracked for failures (which is common when the mirror
does not store any binaries and therefore has no index.json).

This commit ensures that update(..., with_cooldown=True) will
also skip the update even if a failure has occurred within the
cooldown period.
@haampie
Copy link
Member

haampie commented Nov 29, 2022

@scheibelp / @blue42u

The fact that S3 has slow response time should not mean that we should always download a full index. That's primarily an AWS issue.

The index is 80MB of json right now, and it lets you fetch zlib which is a few KB :/

If you have a fast HTTP cache server, and the connection stays alive, you can get response times of a few milliseconds.

On top of that this PR partly causes a rather annoying issue we see in our CI: if index.json.hash is out of sync/unavailable, this causes Spack install to download it every time it's called, because either it doesn't get cached, or the remote content hash is different. Combine that with parallel install and you get tons of redundant re-indexing. A lot of time goes into building a database out of a fetched index.json (even on a slower download constructing the index in memory takes most of the time).

@blue42u
Copy link
Contributor Author

blue42u commented Dec 4, 2022

@haampie

The index.json on develop is pretty-printed and contains a full serialized Spec database. One could imagine a smaller contents.json for the lookups this PR accelerates, which contains just the hashes and pieces that make up the .spec.json filenames:

{
  "23cpnmxeoa44edrzxlwm6pjz6ibo6xg7": {
    "name": "gnuconfig",
    "version": "2021-08-14",
    "arch": {
      "platform": "linux",
      "platform_os": "amzn2",
      "target": "aarch64"
    },
    "compiler": {
      "name": "gcc",
      "version": "7.3.1"
    }
  },
  ...
}

One could also imagine "minifying" either JSON file to remove unneeded whitespace, or gzip-compressing it for fast de/compression via the Python standard library. Some quick preliminary results:

File Total Size (MB) Time to json.load (ms)
index.json 172.0 971
index.json.minified 83.0 840
index.json.gz 11.0 1120
index.json.minified.gz 8.4 948
contents.json 27.0 189
contents.json.minified 18.0 178
contents.json.gz 2.3 222
contents.json.minified.gz 2.2 204

Also, note that spack spec zlib takes ~1 second without a buildcache and ~25 seconds with. Based on the above table, at least 23 of those seconds are unaccounted for.

In conclusion, the fact that Spack has to fetch the full index is primarily because Spack is dumb and always loads a full index, not because AWS is slow responding with 404. Which wouldn't be an issue at all if Spack didn't cause 404s like they're going out of style (and yes, AFAICT it still does).

#BlameSpack

@haampie
Copy link
Member

haampie commented Dec 4, 2022

One could also imagine [...] gzip-compressing it for fast de/compression via the Python standard library.

I would be very surprised if that was not already done automatically.

not because AWS is slow responding with 404.

It is slow to download due to throttling, going well below 1MB/s at times.

at least 23 of those seconds are unaccounted for.

Spack constructs a Database instance from the binary index, and then the problem is that Python is an incredibly slow language.


Still I don't see the point of downloading a full index of n specs from a remote to answer the question wether O(1) specs are contained in them. It's just plain wrong. The fact that Spack locally then also runs in O(n) time to answer that question sure may mean that "Spack is dumb", but you've chosen to end up in a situation like that with this PR.

@haampie
Copy link
Member

haampie commented Dec 4, 2022

Let me just re-iterate this point: spack install /concrete should be O(1) in number of specs (whether it's json or tarballs) downloaded by design. Your point is that in practice doing O(n) operations is faster. My point is that that's the case of one specific host, namely a particular config of aws. That does not make this PR acceptable in general, and I'd rather see it reverted.

@blue42u
Copy link
Contributor Author

blue42u commented Dec 4, 2022

One could also imagine [...] gzip-compressing it for fast de/compression via the Python standard library.

I would be very surprised if that was not already done automatically.

Surprise, it's not. (Verified with Wireshark.)

Spack constructs a Database instance from the binary index, and then the problem is that Python is an incredibly slow language.

Then I ask, why is Spack constructing a full Database instance instead of performing lookups on the dict that comes out of json.load (which takes <1 second)?

It is slow to download due to throttling, going well below 1MB/s at times.

... spack install /concrete should be O(1) in number of specs (whether it's json or tarballs) downloaded by design. Your point is that in practice doing O(n) operations is faster. ...

Before this PR spack install /spec1 /spec2 ... /specS required O(s * m) (m = # of mirrors) requests to complete, O(s * (m - 1)) of which are GET -> 404 and are throttled by AWS. This PR lowered that to O(m + s) of which very few are GET -> 404.

It is true that the bytes downloaded have increased from O(s * m) to O(m * n) (n = # of specs in buildcache). That is slightly unfortunate and I didn't realize it would be an issue until now. I have never observed download throttling from AWS myself in practice, a GET -> 404 response always takes far longer than successfully downloading the index.json in our setup.

To achieve the O(s) you want there needs to be an external argument, such as spack install --use-buildcache package:only:mymirror /concrete. There's no other way < O(m) for Spack to determine which mirror has a package.

My point is that that's the case of one specific host, namely a particular config of aws. That does not make this PR acceptable in general, and I'd rather see it reverted.

Given the Spack public buildcache is hosted on AWS, I would think this is indeed the most important case to the Spack team.

I admit this PR is incomplete (for many reasons), but it does provide significant benefits to us and I'd much rather implement an argument to spack install than have this reverted.

@haampie
Copy link
Member

haampie commented Dec 4, 2022

Surprise, it's not. (Verified with Wireshark.)

Sigh. Of course it doesn't :(

There's no other way < O(m) for Spack to determine which mirror has a package.

O(m) is fine. Another solution is to kick off multiple requests and let the first 200 response win, which is O(m/p). And as a side effect, the fastest response is typically from the mirror with the best download speed.

Given the Spack public buildcache is hosted on AWS, I would think this is indeed the most important case to the Spack team.

Sure, but the solution is to fix the AWS related issue. I'm repeating myself: downloading the full index is not the solution, it's not scalable, it should not be standard behavior of Spack. Whether you manage to speed up other slow parts of Spack or not, this is not how to do it.

@@ -253,6 +257,9 @@ def find_by_hash(self, find_hash, mirrors_to_check=None):
mirrors_to_check: Optional mapping containing mirrors to check. If
None, just assumes all configured mirrors.
"""
if find_hash not in self._mirrors_for_spec:
# Not found in the cached index, pull the latest from the server.
self.update(with_cooldown=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bothers me too about this PR. Please do not make read operations do write operations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caches as a general rule do a write operation on cache miss. That's what's happening here. This should be normal and I was very surprised to find it not doing something like this before.

Copy link
Member

@haampie haampie Dec 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you wrote is more wrong than the previous implementation was wrong. The point is to do a local existence check to order the mirrors. That should never trigger a remote fetch on failure.

@@ -105,6 +106,9 @@ def __init__(self, cache_root):
# cache (_mirrors_for_spec)
self._specs_already_associated = set()

# mapping from mirror urls to the time.time() of the last index fetch.
self._last_fetch_times = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And as far as I can see: this does not help across two spack install calls :(

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was concern for sequences of operations like:

$ spack install ...
$ spack buildcache update-index ...
$ spack install ...

If the fetch timers spanned spack install commands, the latter spack install may (surprisingly) not see the effect of the spack buildcache update-index. Same logic as to why update() has a with_cooldown=False argument.

Note that index.json.hash gets fetched first before index.json (or at least, it's supposed to). So if the index.json didn't change between calls it won't be fetched a second time.

Copy link
Member

@haampie haampie Dec 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We otherwise invalidate cache by mtime, I was thinking it could make sense not to even make a request if (now - mtime) is small enough.

@haampie
Copy link
Member

haampie commented Dec 4, 2022

@blue42u can you please just revert this PR, I see many issues with it.

As a workaround, can you go for this idea

Another solution is to kick off multiple requests and let the first 200 response win, which is O(m/p)

instead?

@blue42u
Copy link
Contributor Author

blue42u commented Dec 4, 2022

@blue42u can you please just revert this PR, I see many issues with it.

@haampie FYI, I do not have write access. I'm an external contributor and part of the HPCToolkit team. This PR is one of a batch I wrote to get our CI working.

I would prefer not to have this PR reverted, without it spack -e concrete_env install takes up way too much time in our CI (like >40 min per job, now it takes ~10 min. Which is still a lot but enough less than running our own tests that I'm not as concerned.)

What I will do is start writing a followup PR that:

  • adds a way to specify which mirror a package should be in the buildcache of,
  • an optimization that the BinaryCacheIndex is not checked if we're --cache-only and only one mirror would be checked, and
  • a tweak to spack ci to write settings for the above in generated pipelines based on it's omniscience having fetched all the indices already.

All those together will cost O(s) requests with O(s) bytes transferred for the build jobs. That's very scalable and doesn't tickle the AWS throttling.

@haampie
Copy link
Member

haampie commented Dec 4, 2022

Before you do all that, did you try time curl https://binaries.spack.io/develop/this/does/not/exist? I'm getting <700ms on the first and <200ms on the next attempt (on wifi). Have you tried using https for the fetch part of the mirror? Have you checked if the s3 slowness is thanks to built-in retries in boto3/botocore? Or maybe due to creating a client instance per request instead of reusing it? I seriously doubt 3-5 seconds is normal, and before we get tons of additional ad-hoc fixes for this problem, I'd rather be sure it is a problem we have to fix with workarounds.

Edit: I quickly tried the following example:

import spack.util.s3
import time

def slow(key):
    try:
        t = time.time()
        s = spack.util.s3.create_s3_session("s3://spack-binaries")
        s.head_object(Bucket="spack-binaries", Key=key)
    finally:
        print(time.time() - t)


session = spack.util.s3.create_s3_session("s3://spack-binaries")
def faster(key):
    try:
        t = time.time()
        session.head_object(Bucket="spack-binaries", Key=key)
    finally:
        print(time.time() - t)
  • slow("develop/x/y") gives 2s the first attempt and then 0.8s afterwards.
  • faster("develop/x/y") gives 0.6s the first try and 0.13s on subsequent requests, probably it keeps the connection open better.

Would probably be better to ensure we don't instantiate a new client each time, and look into these kinds of things in general, before even considering PRs like these.

@blue42u
Copy link
Contributor Author

blue42u commented Dec 5, 2022

Have you tried using https for the fetch part of the mirror? Did you try time curl https://binaries.spack.io/develop/this/does/not/exist?

Now that I have, I get timings of 100-450ms, with later attempts mostly on the lower end of that range. Unfortunately AFAIK we can't use an https URL in our CI, we use a private S3 bucket and it needs S3 authentication to access at all.

I quickly tried the following example: ...

I tried a one-line version of your example, on my development machine (wired connection):

$ ./bin/spack python -m timeit -v -s 'import spack.util.s3 as s3' 'try: s3.create_s3_session("s3://spack-binaries").head_object(Bucket="spack-binaries", Key="does/not/exist")' 'except Exception: pass'
$ ./bin/spack python -m timeit -v -s 'import spack.util.s3 as s3' -s 's = s3.create_s3_session("s3://spack-binaries")' 'try: s.head_object(Bucket="spack-binaries", Key="does/not/exist")' 'except Exception: pass'

If boto3 authenticates (i.e. I have a key in .aws/credentials), these report timings of 450-650ms, with later requests mostly on the lower end of that range. BUT if boto3 does not authenticate (i.e. I remove .aws/credentials), the former reports a timing of 2.5-2.6 seconds while the latter is unchanged. So agreed, there is definitely a large cost to create_s3_session and Spack should cache those when possible.

(I imagine the difference in timings between s3:// and https:// is because of the CloudFront caches.)

I also tried using Key="develop/build_cache/index.json.hash and using get_object, the timings did not change. Which means AWS isn't throttling 404s like I thought. That's my misinterpretation of events, sorry for spreading misinformation. 😞


So, reassessment of the situation:

The reason this PR helps is because it reduces the number of calls to try_direct_fetch by update()'ing the BinaryIndexCache on miss. Without an updated BinaryIndexCache, every spec not present in the buildcache will call this function and generate 6 GET requests (for .spec{.json.sig,.json,.yaml}{,/index.html}). Or about 15 seconds unauthenticated based on numbers above.

Note also that download_single_spec requires up to 6 requests itself (the first up to 4 requests are 404s). This is one cause behind the large number of 404 requests I still see in our CI logs. I've raised the issue before but a proper solution requires larger refactors.

This PR hurts @haampie's case because it causes more calls to _fetch_and_cache_index in CI build jobs, which calls url_exists (here), which fully fetches the index.json for S3 URLs. AFAICT _fetch_and_cache_index may also perform duplicate fetches when run in parallel, the fetch occurs before the write_transaction critical section.

So, suggested changes (in order of roughly increasing difficulty):

  1. url_exists needs to use head_object instead of get_object for S3 URLs (here). This prevents actually downloading the huge index.json every time.
  2. The code that reads S3 URLs should not attempt to suffix /index.html when a key isn't found (here). This halves the number of 404 requests from try_direct_fetch and download_single_spec.
  3. _fetch_and_cache_index and update() need to be reviewed and adjusted to behave properly in parallel. Specifically, only one parallel invocation should ever fetch index.json, all others should at most fetch index.json.hash.
  4. create_s3_connection needs to cache the boto3 Clients for request speed. This speeds up most S3 operations by ~5x.
  5. The O(s) algorithm in my previous comment needs to be implemented. This prevents the build jobs in a spack ci pipeline from fetching the index.json at all.

@haampie I think this sequence would be enough to fully solve your scenario, do you see any issue in what I've suggested?

@haampie
Copy link
Member

haampie commented Dec 5, 2022

Makes sense.

  1. This point is fine, but note that in many cases it's better to just get_object and write things in a "tell don't ask" fashion, cause obviously head_object followed by get_object runs into a race anyways, and it does 2 requests in the success case.
  2. Lmao. Just why did anyone think that's a good idea. These things make me want to pull my hair out.
  3. We should just not fetch index.json 🤷 mirrors for pre-built binaries should work like mirrors for sources, just go over the mirrors one by one.
  4. This should be high priority
  5. Again, I'd rather stick to linear search of mirrors, or possibly doing async requests and fetching from the first mirror to respond. 🤷‍♂️

haampie added a commit to haampie/spack that referenced this pull request Dec 5, 2022
PR spack#32137 was based on the wrong assumption that direct fetches from
mirrors are necessarily slow.

The solution (fetch the index to locally see if something is in it) is
not scalable.

Instead, there's plenty of space to improve the current s3:// fetch
logic: it shouldn't create a client every time, the number of 404s can
be halved by not trying to fetch s3://[url]/index.html if s3://[url]
responds with 404. Etc.
@haampie haampie mentioned this pull request Dec 5, 2022
@blue42u
Copy link
Contributor Author

blue42u commented Dec 5, 2022

Connecting dots since @haampie is at it over here:

  1. Use urllib handler for s3:// and gs://, improve url_exists through HEAD requests #34324
  2. Stop checking for {s3://path}/index.html #34325

My own comments:
4: Agreed.
3+5: I for one don't want to pay 15s * 50 packages * 3 mirrors = 37.5 minutes of wasted time in every one of our CI jobs. That's just ludicrous. I would rather spend the extra time to implement (5) and remove the problem completely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
binary-packages core PR affects Spack core functionality defaults tests General test capability(ies)
Projects
No open projects
Development

Successfully merging this pull request may close these issues.

None yet

5 participants