I almost failed to search a 37 GB text file in under 1 millisecond
death.andgravity.com
death.andgravity.com
Compute the SHA1 of each password. Sort. Store in a binary file, so for 'n' passwords the file is '20n' bytes in length exactly.
Compute the SHA1 of the candidate password, and use the prefix to guess at the location in the file. Read a block of +/- a few kilobytes around that guessed location. This will have a "hit" about 99% of the time, and at this guess will have to be refined at most 2-3 times.
It's possible to precompute the maximum +/- error bounds, and also to produce a tiny (~100KB) second level index that can guarantee a hit in one I/O operation for a lookup. For a file that's bigger than memory, this is absolutely optimal and cannot be improved upon (without sacrificing the exact answers).
Basically, a binary search?
For instance if we were talking about n random sequences of digits then if you want to look for a number starting with 42 then you can start looking at the the 0.42n element and it is likely already very close to a match.
With the method described, you would measure the thickness of the book (the size of the lookup file) and open to a proportional page. e.g. If you're looking for page 120 and the book is 360 pages long, that's 1/3 of the thickness. So, find the point that's a third of the thickness of the book and open the book there. You'll be pretty close.
With a binary search, you wouldn't measure the thickness of the book. You'd always just blindly start in the middle, assess which half the target page is, and repeat the operation on that half and you'll get there pretty quickly.
Both exploit the knowledge of how the data is organized and both are most well-suited to uniform data (like pages in a book or random hashes), but are just different strategies requiring different means of accessing the data.
Admittedly not a perfect analogy, but you get the idea.
> Instead of calculating the midpoint, interpolation search estimates the position of the target value, taking into account the lowest and highest elements in the array as well as length of the array.
Sound pretty close to what you're describing :-)
[1] https://en.m.wikipedia.org/wiki/Binary_search_algorithm#Inte...
In which case the obvious improvement is to load as much of the password file into RAM as possible while waiting for the request and then only search the disk if it’s non in RAM. As to whether you want to load prefixes so you can quickly reject passwords not in the file or full passwords to acknowledge the password as soon as possible probably depends on how likely the matches are.
But a crypto hash like SHA may be a wrong hash here. Something like murmur / city / metro hash may be a better solution, since they compute 3-5 bytes of hash per clock.
Preprocessing the file and adding hashes isn’t free.
Sorting is very much not free though. I don't see why would sorting be needed if you have an efficient hash table, or a search tree. Essentially TFA describes just that, an index file + data file approach. They could have used SQLite which readily provides indexed access, instead of pure Python.
Of course if you never do the same search again, you're back to mmap-ing the file and doing a full scan smartly, like GNU grep does (https://lists.freebsd.org/pipermail/freebsd-current/2010-Aug...).
It’s an arbitrary problem, so you could argue that pre computation is fine but you need to assume a cold cache or whatever.
Preprocessing is always an option: just needs some ahead of time effort.
A hot cache for a 37GB file is not always an option.
Aka are you building a service or grep.
If that is not possible, precompute the average count representation length to get an accurate AVG offset per entry. Accurate in aggregate.
But I wonder if I wouldn’t use some prefix representation, like a trie, but that’s cheating I think
Edit: Wikipedia says perfect hashes have O(1) runtime for a given dataset. This framing is a bit weird, as I would think the size of "1" still depends on "n". Also Wikipedia gives ~1.44 bits/key as complexity so this should be much better than the 9 bytes the article came up with.
So if i understand correctly the variance of the data would not really matter since we will choose a hash table of size of the total space of passwords thus absolutely no collisions will occur no matter which distribution of keys we pick and hash
Thats where the O(1) comes from since searching for a key in a hash slot would take constant time as there is just one key in each slot (due to no collisions)
As the other commentor said the SHA1 collision has such a low probability that theres no need for perfect hashing
That way you jump from look-up table to look-up table, and then search the remaining space with something like a binary search.
create table tableName (.....) Engine = Memory();
Here is a slide deck showing why MergeTree is faster than in-memory tables: https://presentations.clickhouse.com/meetup53/optimizations/
>>> con.execute("CREATE TABLE passwords (hash TEXT, count INT)")
<duckdb.DuckDBPyConnection object at 0x7fc7bceb55f0>
>>> con.execute("CREATE INDEX ix_hash ON passwords (hash)")
<duckdb.DuckDBPyConnection object at 0x7fc7bceb55f0>
>>> con.execute("COPY passwords FROM 'pwned-passwords-sha1-ordered-by-hash-v8.txt' (SEPARATOR ':')")
100%
100%
It froze in an attempt to load the data. Nothing happens after it displays 100%.The reason is probably that it's using a full index, in contrast with the sparse index in ClickHouse, and maybe it's trying to build it in memory, going to swap (the server has 32 GB memory).
ubuntu@ip-172-31-3-138:~$ ls -l
total 69561648
-rw-rw-r-- 1 ubuntu ubuntu 17631031296 Dec 16 23:57 my-db.duckdb
-rw-rw-r-- 1 ubuntu ubuntu 326 Dec 16 23:53 my-db.duckdb.wal
-rw-rw-r-- 1 ubuntu ubuntu 16257755606 Jan 21 2022 pwned-passwords-sha1-ordered-by-hash-v8.7z
-rw-rw-r-- 1 ubuntu ubuntu 37342268646 Dec 2 2021 pwned-passwords-sha1-ordered-by-hash-v8.txt
ubuntu@ip-172-31-3-138:~$ python3
Python 3.10.6 (main, Nov 14 2022, 16:10:14) [GCC 11.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import duckdb
>>> con = duckdb.connect(database='my-db.duckdb')
>>> con.execute("SELECT count(*) FROM passwords").fetchall()
[(0,)]I'm also trying to follow every existing technology in the data engineering space :)
D CREATE TABLE passwords (hash TEXT, count INT);
D COPY passwords FROM '~/Downloads/pwned-passwords-sha1-ordered-by-hash-v8.txt' (SEPARATOR ':');
D .timer on
D SELECT \* FROM passwords WHERE hash=upper('5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8');
┌──────────────────────────────────────────┬─────────┐
│ hash │ count │
│ varchar │ int32 │
├──────────────────────────────────────────┼─────────┤
│ 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 │ 9545824 │
└──────────────────────────────────────────┴─────────┘
Run Time (s): real 0.005 user 0.007455 sys 0.000584
[1] https://duckdb.org/docs/sql/indexesYou should also disclose your relationship with a competing project. For the record, I use DuckDB in personal projects and love it. You seem to be misusing it. :)
4e17b76fc101c9db7222e0cd8d6f5eee pwned-passwords-sha1-ordered-by-hash-v8.txt
select count(*) from read_csv('pwned-passwords-sha1-ordered-by-hash-v8.txt', delim=':', header=False, columns={'Hash': 'VARCHAR', 'Count': 'INT'});
60.32s, 847223402 rows create table hashes as select * from ...
OOM :(
set PRAGMA temp_directory create table ...
144.92s (83.19s on BATCH CREATE, 61.53s on READ CSV) select \* from hashes where Hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511'; -- secret123
0.0269s -- 1st
0.0043s -- 2nd
0.0026s -- 3rd
0.0062s -- 4th
0.0047s -- 5th
edits: attempt to fix formattingGranted, I’d expect one or two disk seeks, at ~ 10 ms each. I imagine on modern hardware, it would be in the 100’s of usec range. (Assuming you limited it to 2-4 GB of ram).
time pwned.sh password
5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8:9545824
0.053u 0.035s 0:00.40 20.0% 83+22k 35+0io 21pf+0w
time python3.10 02-binary-search.py pwned-passwords-sha1-ordered-by-hash-v8.txt password
looking for 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
pwned! seen 9,545,824 times before
in 0.090021 seconds
0.105u 0.013s 0:00.44 25.0% 0+3k 32+0io 4pf+0w
Obviously, if you're doing millions of lookups you'll want indexing, but otherwise using built-in tools that have been optimized and refined for decades does very well.In theory, hashes are going to be very evenly distributed. With a file of that size you could probably land within 1% of the correct location just by seeking to byte offset percentage given by the first bytes of the hash...
(It's a fun article)
I don't get why you can't do this at each step.
I gave up because I was too lazy to think how to do the "precompute the maximum +/- error bounds" jiggawatts mentions in https://news.ycombinator.com/item?id=34021826
What would be the fastest way using *nix commands? A naive solution would be something like:
echo -n password | sha1sum | cut -d ' ' -f 1 | xargs -I hash grep hash pwned.txtOr just use ripgrep, which integrates multi-core.
xargs -P maxprocs
Parallel mode: run at most maxprocs invocations of utility at once. If maxprocs is set to 0, xargs will run as many processes as possible.
https://www.gnu.org/software/bash/manual/html_node/Bash-Vari...
Then, after typing your password you can safely use the $MY_PASSWORD variabile
look $(echo -n password | sha1sum | cut -d ' ' -f 1 | tr a-z A-Z) pwned.txt
from man page:NAME
look - display lines beginning with a given string
DESCRIPTION
The look utility displays any lines in file which contain string. As look performs a binary search, the lines in file must be sorted (where sort(1) was given the same options -d and/or -f that look is invoked with).
example:
justin@box:~/data$ time look $(echo -n secret123 | sha1sum | cut -d ' ' -f 1 | tr a-z A-Z) pwned-passwords-sha1-ordered-by-hash-v6.txt
F2B14F68EB995FACB3A1C35287B778D5BD785511:17384
real 0m0.212s
user 0m0.005s
sys 0m0.001s
justin@box:~/data$ time look $(echo -n secret123 | sha1sum | cut -d ' ' -f 1 | tr a-z A-Z) pwned-passwords-sha1-ordered-by-hash-v6.txt
F2B14F68EB995FACB3A1C35287B778D5BD785511:17384
real 0m0.002s
user 0m0.003s
sys 0m0.001sOn my laptop, look `time`s at ~10 ms (for comparison, the Python "binary search" script `time`s at ~50 ms).
import os
import mmap
def do_mmap(f):
fd = os.open(f, os.O_RDONLY)
size = os.lseek(fd, 0, 2)
os.lseek(fd, 0, 0)
m = mmap.mmap(fd, size, prot=mmap.PROT_READ)
return m, size, fd
SEEK_SET = 0
SEEK_CUR = 1
class Searcher:
def __init__(self, file):
self.file = file
self.map, self.size, self.fd = do_mmap(file)
def close(self):
self.map.close()
os.close(self.fd)
def find_newline(self):
self.map.readline()
return self.map.tell()
def binary_search(self, q):
pos = 0
start = 0
end = self.size
found = False
#this can get stuck with start = xxx and end = xxx+1, probably from the \r\n
while start < end - 2:
mid = start + (end-start)//2
self.map.seek(mid)
pos = self.find_newline()
if pos > end:
break
line = self.map.readline()
if q < line:
end = mid
elif q > line:
start = mid
while True:
line = self.map.readline()
if not line.startswith(q): break
yield line
if __name__ == "__main__":
import sys
q = sys.argv[1]
s = Searcher("pwned-passwords-sha1-ordered-by-hash-v6.txt")
import time
ss = time.perf_counter()
res = s.binary_search(q.upper().encode())
for x in res:
print(x)
ee = time.perf_counter()
print(ee-ss)I ended up not mentioning it because for some reason, it was ~twice as slow on my mac... I'm now curious to try it on a decent Linux machine.
There is a bit over 1B passwords in there (based on the size of the file and the length of the line). You would need a binary file around 3GB in size that would have to either load into memory or do about 17 accesses to read specific bytes ( no searching) to figure out if the password is in the filter.
* https://scotthelme.co.uk/when-pwned-passwords-bloom/
* https://scotthelme.co.uk/sketchy-pwned-passwords/ – here he uses a count-min sketch to also store the frequency
If I had the ability to download a massive file I’d try it out on a hextree I toy around with occasionally.
If you’re making an index file may as well just throw it into a tree structure where a lookup is anywhere from 1 to 20 pointer dereferences (assuming the checksum is 20 hex digits) as it optimizes storage so tree depth is variable. Plus it can retain the counts as well.
Now I really want to try this out, the last article I read along these lines I used it as a comparison and it was equally as efficient as their conclusion.
If I served a site where people could check if their passwords leaked I would not worry if one in a million viewers got a false positive.
plonk
For example, if you want to look up the existence of a sample hash: `2aae6c35c94fcfb415dbe95f408b9ce91ee846ed`
Then simply check for existence of directory <data-root>/2a/ae/6c/35/etc...
I was looking at the directory structure of Gitea's Docker Container Registry and this is how they stored container images.
I'm sure it will go at okay speed once you actually construct it, since it's basically a tree, but with so many entries I'd expect it to be much less pleasant than a database in many ways. The "preprocessing" is going to be especially awful.
But I'm curious on how lookup speeds would compare to the author's 1ms.
I'm also curious on how addition of a new hash would compare against adding a new hash to the single sorted file used by the author.
Leveraging any database is probably better in any case :)
> I'm also curious on how addition of a new hash would compare against adding a new hash to the single sorted file used by the author.
In a fair fight with that requirement, the sorted file would be allowed to add a few percent of extra blank entries and then it could insert in a millisecond too.
Actually, these days many filesystems perform surprisingly well with lots of little files. What don't work with huge directories is basic utilities like ls, or anything that likes to collect file list in memory & sort it. I have some directories that essentially hang ls, where find is still happy listing the files (because it just streams the output).
what a scummy headline
The simple statement of the approach is: Use binary search, but instead of using the middle as the pivot, use the estimated position heuristic as the pivot for each iteration.
> The idea is to do an "unbalanced" binary search, with the pivot chosen as offset (range size * big-endian value of hash / (maximum possible big-endian value of hash + 1)).
Thanks. I thought something along these lines but didn't know how to express or formalize it. Now I must study unbalanced binary search algorithms.
This does not pass a sanity check. If one switches to linear scan at 4KiB = 2^12 bytes, then 30 (balanced, but approximately correct for unbalanced) binary search steps would search a 4TiB file. I don't know how big the hash file is but it isn't this big. Not even 4GiB big.
The maximum number of steps is lg(file size)-12, maybe plus 2 or so to account for the unbalanced search. (NB the unbalanced search approximates the balanced only because the hashes are approximately uniform; the relation doesn't hold in general.)
For some reason, after that, `ls` in that directory (or even shell completion) would freeze the shell entirely, even after reboot. I eventually managed to delete the file with `rm db.sqlite` (no completion allowed), but even that took like 2 minutes.
I might try again with WAL enabled (based on my shell history, I also deleted a -journal file).
SQLite version 3.37.0 2021-12-09 01:34:53
here's the results tho
sqlite> create table hashes (hash text, count text);
sqlite> pragma cache_size=1000;
sqlite> pragma synchronous=off;
sqlite> .mode csv
sqlite> .separator :
sqlite> .timer on
sqlite> .import pwned-passwords-sha1-ordered-by-hash-v8.txt hashes
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
^CRun Time: real 14.841 user 9.370057 sys 2.363629
Error: stepping, interrupted (9)
sqlite> create unique index hash_uniq on hashes(hash);
Run Time: real 530.029 user 262.952507 sys 213.305437
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
F2B14F68EB995FACB3A1C35287B778D5BD785511:26437
Run Time: real 0.004 user 0.000303 sys 0.000936
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
F2B14F68EB995FACB3A1C35287B778D5BD785511:26437
Run Time: real 0.001 user 0.000310 sys 0.000306
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
F2B14F68EB995FACB3A1C35287B778D5BD785511:26437
Run Time: real 0.000 user 0.000295 sys 0.000305
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
F2B14F68EB995FACB3A1C35287B778D5BD785511:26437
Run Time: real 0.000 user 0.000320 sys 0.000279
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
F2B14F68EB995FACB3A1C35287B778D5BD785511:26437
Run Time: real 0.001 user 0.000322 sys 0.000317
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
F2B14F68EB995FACB3A1C35287B778D5BD785511:26437
Run Time: real 0.001 user 0.000128 sys 0.000155
sqlite> select * from hashes where hash = 'F2B14F68EB995FACB3A1C35287B778D5BD785511';
F2B14F68EB995FACB3A1C35287B778D5BD785511:26437
Run Time: real 0.001 user 0.000145 sys 0.000158Off the top of my head, I don't really know how to do that (aside from the naive way of using threads), I'm guessing readv[1] might be be useful? Will definitely look into it.
echo 3 | sudo tee /proc/sys/vm/drop_caches
for benchmarks to avoid disk cache speeding things up, i'm surprised it wasn't mentionedI have another article that covers profiling in more detail, you might find it useful: https://death.andgravity.com/fast-conway-cubes#intro-to-prof...
Note that besides the cProfile text output, there are better, graphical tools for profiling; I've personally used pyflame+flamegraph.pl, and tuna.
The linked item is to https://blog.mro.name/2022/08/pwned-diy/, where the author converts the passwords list to a CDB (Constant DataBase) file, a simple, fast lookup database format created by D. J. Bernstein.
[1]: https://search.feep.dev/blog/post/2022-12-03-cdb-file-format
Source: https://cr.yp.to/cdb/cdb.txt
The dataset is turned to 16 cdbs to be precise in case you wonder how 37GB fit into the 4GB cdb size limit.
If the input file is even larger and you have a lot of RAM, use your favorite programming language and call mmap() syscall.
How would one fix that?
This is not how one does binary search.
"Don't be snarky."
"Please don't post shallow dismissals, especially of other people's work. A good critical comment teaches us something."
https://ai.googleblog.com/2006/06/extra-extra-read-all-about...
it's an algorithm where it's easy to have off-by-one errors that lead to nontermination or incorrect results for certain inputs
integer overflow is also a problem on some platforms, especially java
I suspect it is as usual, someone made theoretical invention, then someone else looked at it years later and wrote implementation in evening or two