Skip to content

Commit 389f91a

Browse files
committed
Use xxh64 by default and remove the 2 bytes prefix from the hashes.bin for even better compression
1 parent 77a3a54 commit 389f91a

3 files changed

Lines changed: 50 additions & 22 deletions

File tree

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ Typical use cases:
3535
FastSet uses a **sorted fingerprint array** instead of storing the original strings.
3636

3737
### Build time
38-
1. Read a newline-separated dictionary file.
39-
2. Hash each entry using **`xxh128`** (16 bytes, raw).
38+
1. Read to original dictionary built using the `SetBuilder`
39+
2. Hash each entry using the configured hash
4040
3. Sort all fingerprints.
4141
4. Write two files:
42-
- `hashes.bin` – concatenated 16-byte fingerprints
42+
- `hashes.bin` – concatenated fingerprints (without the 2-byte prefixes for maximum compression)
4343
- `index.bin` – a 2-byte prefix index (65,537 offsets)
4444

4545
### Lookup time
@@ -48,15 +48,16 @@ FastSet uses a **sorted fingerprint array** instead of storing the original stri
4848
3. Binary-search **only inside that bucket**.
4949

5050
Because hash prefixes are uniformly distributed, buckets are tiny.
51-
Real world tests on the fixture file that contains ~140k entries in this repository (`tests/Fixtures/terms_de.txt`):
51+
Real world tests on the fixture file that contains ~140k entries in this repository (`tests/Fixtures/terms_de.txt`)
52+
using the `xxh128` hash algorithm:
5253

5354
- Average bucket size `≈2–3` entries
5455
- Worst case (real data): `11` entries
5556
- Worst case comparisons: `log₂(11) = 4`
5657

5758
Of course, the bigger your dictionary, the bigger the individual buckets.
5859

59-
All comparisons are fixed-width (16 bytes), not variable-length UTF-8 strings.
60+
All comparisons are fixed-width, not variable-length UTF-8 strings.
6061

6162
---
6263

@@ -117,9 +118,13 @@ SetBuilder::buildSet($myOriginalSet, './compressed.gz');
117118
// array directly:
118119
SetBuilder::buildFromArray($myArray, './compressed.gz');
119120

121+
// You can use either xxh64 (default) or xxh128
122+
// xxh128 will have a smaller probability for false-positives but require more memory
123+
$hashAlgorithm = 'xxh64';
124+
120125
// You then ship either "compressed.txt" or "compressed.gz" with your application. Instantiating
121126
// is then done as follows:
122-
$set = new FastSet(__DIR__ . '/dict');
127+
$set = new FastSet(__DIR__ . '/dict', $hashAlgorithm);
123128
$set->build(__DIR__ . '/compressed.(txt|gz)'); // Must be a file built using the SetBuilder
124129
```
125130

src/FastSet.php

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ final class FastSet
1010

1111
private readonly string $indexPath;
1212

13+
private readonly int $fingerprintByteLength;
14+
15+
private readonly int $storedTailByteLength;
16+
1317
private bool $isInitialized = false;
1418

1519
private string $blob = '';
@@ -33,18 +37,27 @@ final class FastSet
3337
*/
3438
private array $prefixOffsets = [];
3539

36-
public function __construct(private readonly string $directory)
37-
{
38-
$this->hashesPath = $this->directory.'/hashes.bin';
39-
$this->indexPath = $this->directory.'/index.bin';
40-
40+
public function __construct(
41+
private readonly string $directory,
42+
private readonly string $hashAlgorithm = 'xxh64',
43+
) {
4144
if (!is_dir($this->directory)) {
4245
throw new \InvalidArgumentException('Directory does not exist.');
4346
}
4447

45-
if (!\in_array('xxh128', hash_algos(), true)) {
46-
throw new \LogicException('Requires xxh128 hash algorithm.');
48+
if (!\in_array($this->hashAlgorithm, ['xxh64', 'xxh128'], true)) {
49+
throw new \LogicException(\sprintf('Unsupported hash algorithm "%s". Use "xxh64" or "xxh128".', $this->hashAlgorithm));
50+
}
51+
52+
if (!\in_array($this->hashAlgorithm, hash_algos(), true)) {
53+
throw new \LogicException('Desired hash algorithm is not available.');
4754
}
55+
56+
$this->fingerprintByteLength = 'xxh128' === $this->hashAlgorithm ? 16 : 8;
57+
$this->storedTailByteLength = $this->fingerprintByteLength - 2;
58+
59+
$this->indexPath = $this->directory.'/index_'.$this->hashAlgorithm.'.bin';
60+
$this->hashesPath = $this->directory.'/hashes_'.$this->hashAlgorithm.'.bin';
4861
}
4962

5063
public function has(string $entry): bool
@@ -68,15 +81,19 @@ public function has(string $entry): bool
6881
return false;
6982
}
7083

84+
$queryFingerprintTailBytes = substr($fingerprint, 2, $this->storedTailByteLength);
85+
7186
// Binary search within that bucket
7287
$low = $startIndex;
7388
$high = $endIndex - 1;
7489

7590
while ($low <= $high) {
7691
$mid = $low + $high >> 1;
77-
$midFingerprint = substr($this->blob, $mid * 16, 16);
7892

79-
$cmp = strcmp($midFingerprint, $fingerprint);
93+
$middleTailByteOffset = $mid * $this->storedTailByteLength;
94+
$middleFingerprintTailBytes = substr($this->blob, $middleTailByteOffset, $this->storedTailByteLength);
95+
96+
$cmp = strcmp($middleFingerprintTailBytes, $queryFingerprintTailBytes);
8097
if (0 === $cmp) {
8198
return true;
8299
}
@@ -118,8 +135,9 @@ function (string $entry) use (&$fingerPrints): void {
118135
$prefixKey = $this->getPrefixKey($fingerprint);
119136
++$prefixCounts[$prefixKey];
120137

121-
// Each fingerprint is exactly 16 bytes
122-
fwrite($hashFile, $fingerprint);
138+
// Skip the first 2 bytes in our hashes.bin - they are already part of the index.bin
139+
// so we can save 2 bytes per fingerprint to reduce our memory footprint even more
140+
fwrite($hashFile, substr($fingerprint, 2, $this->storedTailByteLength));
123141
}
124142

125143
fclose($hashFile);
@@ -168,7 +186,7 @@ public function getPrefixKey(string $fingerprint): int
168186

169187
private function getFingerPrintForEntry(string $entry): string
170188
{
171-
return hash('xxh128', $entry, true);
189+
return hash($this->hashAlgorithm, $entry, true);
172190
}
173191

174192
/**

tests/FastSetTest.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Toflar\FastSet\Tests;
66

7+
use PHPUnit\Framework\Attributes\TestWith;
78
use PHPUnit\Framework\TestCase;
89
use Symfony\Component\Filesystem\Filesystem;
910
use Toflar\FastSet\FastSet;
@@ -31,21 +32,25 @@ public function testFastSetFailsWithWrongFileFormat(): void
3132
$fastSet->build(__DIR__.'/Fixtures/terms_de.txt');
3233
}
3334

34-
public function testWorkingWithSetBuilderWithoutGzipCompression(): void
35+
#[TestWith(['xxh64'])]
36+
#[TestWith(['xxh128'])]
37+
public function testWorkingWithSetBuilderWithoutGzipCompression(string $hashAlgorithm): void
3538
{
3639
// Build a set without gzip but with our prefix algorithm
3740
SetBuilder::buildSet(__DIR__.'/Fixtures/terms_de.txt', $this->testDirectory.'/terms_encoded.txt');
3841

3942
// File size of the encoded file must be definitely smaller
4043
$this->assertTrue(filesize($this->testDirectory.'/terms_encoded.txt') < filesize(__DIR__.'/Fixtures/terms_de.txt'));
4144

42-
$fastSet = new FastSet($this->testDirectory);
45+
$fastSet = new FastSet($this->testDirectory, $hashAlgorithm);
4346
$fastSet->build($this->testDirectory.'/terms_encoded.txt');
4447

4548
$this->assertFastSetContents($fastSet);
4649
}
4750

48-
public function testWorkingWithSetBuilderWithGzipCompression(): void
51+
#[TestWith(['xxh64'])]
52+
#[TestWith(['xxh128'])]
53+
public function testWorkingWithSetBuilderWithGzipCompression(string $hashAlgorithm): void
4954
{
5055
// Build a set without gzip but with our prefix algorithm
5156
SetBuilder::buildSet(__DIR__.'/Fixtures/terms_de.txt', $this->testDirectory.'/terms_encoded.txt');
@@ -57,7 +62,7 @@ public function testWorkingWithSetBuilderWithGzipCompression(): void
5762
// File size of the gzipped file must be even smaller
5863
$this->assertTrue(filesize($this->testDirectory.'/terms_gzipped.gz') < filesize($this->testDirectory.'/terms_encoded.txt'));
5964

60-
$fastSet = new FastSet($this->testDirectory);
65+
$fastSet = new FastSet($this->testDirectory, $hashAlgorithm);
6166
$fastSet->build($this->testDirectory.'/terms_gzipped.gz');
6267

6368
$this->assertFastSetContents($fastSet);

0 commit comments

Comments
 (0)