-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcreate_index.go
More file actions
2256 lines (2141 loc) · 87.1 KB
/
Copy pathcreate_index.go
File metadata and controls
2256 lines (2141 loc) · 87.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"bufio"
"fmt"
"io"
"math"
"os"
"runtime"
"sort"
"time"
"unsafe"
)
// Return the approximate amount of memory in MiB required to run CreateIndex()
// for a file of length inputFileSize (in bytes) with the specified parameters.
func EstimateMemUsage(inputFileSize uint64, guaranteedDuplicateLength uint64, maxMatchesPerDupe int) int64 {
chunkSize := guaranteedDuplicateLength / 2
if (maxMatchesPerDupe == math.MaxInt) || (maxMatchesPerDupe <= 0) {
// A large but feasible maximum
maxMatchesPerDupe = 10000
}
maxChunkMatchesSize := uint64(unsafe.Sizeof(uint64(0))) * uint64(maxMatchesPerDupe+1)
var ac AnyCandidate
maxMemUsePerAnyCand := uint64(unsafe.Sizeof(ac)) + Max(
uint64(unsafe.Sizeof(DupeIndexEntry{})),
uint64(unsafe.Sizeof(CandidateDupe{})),
uint64(unsafe.Sizeof(RepeatingCandidateDupe{})),
)
// Memory used by the hash tables (in bytes)
numChunks := inputFileSize / chunkSize
memReq := numChunks * 24
// Memory used by inputFileBuffer
memReq += InputFileBufferCap
// Very rough estimates of the internal working memory of the goroutines
// performing each step of the process and the channels between them,
// excluding the contributions above:
//
// CalcRollingHashes() and hashChan
memReq += 1*MiB + 50*chunkSize
// FindDuplicateHashes()
memReq += 1 * MiB
memReq += (32 + maxChunkMatchesSize) * DedupChanCap * NumParallelHashTables
// duplicateChunkChan
memReq += maxChunkMatchesSize * DedupChanCap
// CompileCandidatesMultithreaded()
chunkMatchesBufPerThread := (2*CompCandsSectionSizeMult + 2) * chunkSize
memReq += maxChunkMatchesSize * chunkMatchesBufPerThread * CompCandsNumThreads
memReq += maxMemUsePerAnyCand * CompCandsCandidateBufSize * CompCandsNumThreads
memReq += 1 * MiB
// candidateChan
memReq += maxMemUsePerAnyCand * DedupChanCap
// WriteIndex()
memReq += WriteIndexBufferChanMemSize
memReq += WriteIndexMaxCacheMemory
memReq += 1 * MiB
// Add a ~15% margin both to account for possible inaccuracy and to give
// the garbage collector some headroom
memReq += memReq / 7
// Likewise add a fixed 1024MiB margin
memReq += 1024 * MiB
// Return the total in MiB
return int64(memReq / MiB)
}
// Perform the first pass of the deduplication operation. Populate the output
// file's headers and write an index of the duplicates found in the input file.
//
// The index will be optimal in the sense that all duplicates of length
// guaranteedDuplicateLength, as well as some shorter ones, will be included,
// and where there are multiple possible duplicates, that which gives the
// greatest reduction in size of the output file will be chosen. For performance
// reasons, only the most recent maxMatchesPerDupe of the possibilities will
// be considered.
//
// In the event of a fatal error the operation will terminate immediately
// and the error will be returned. If this function returns nil, the file
// at outputFilePath will be have fully populated headers and a completed
// index of duplicates, but will lack the raw non-duplicate data.
//
// Prints a progress indicator to stdout during the operation. This will not
// have a trailing newline when the function returns.
//
// The duplicates are found in 4 (parallel) steps as follows:
// - The file is passed through a rolling hash function, producing hash values
// for every contiguous substring of bytes (herafter 'chunk') of some length
// (hereafter 'chunkSize').
// - The values corresponding to chunks which start at offsets which are
// multiples of chunkSize, are stored in hashtables, which are then used to
// identify when the same substring occurs again later in the file. This
// will identify at least some part of all duplicates with lengths of at
// least 2*chunkSize (and some as short as chunkSize).
// - This information on matching chunks is compiled and refined to a set of
// possible candidates for the duplicates which should be written to the
// index.
// - Since we have only identified duplicates in chunks, some number of bytes
// before and after a candidate duplicate may or may not also be duplicated,
// so we do a byte-by-byte comparison to determine the full length of each
// duplicate, using an in-memory buffer of the most recently read part
// of the file or, failing that, by rereading from the input file as needed.
// Hence the full length of all candidate duplicates is found and the
// optimal (ie longest) duplicates are recorded in the index.
//
// Diagrammatically:
//
// ~~~~~~~~~~~~~
// { inputFile }
// ~~~~~~~~~~~~~
// |
// V
// _____________________
// | CalcRollingHashes | -------------------¬
// """"""""""""""""""""" |
// | |
// | |
// hash values |
// | |
// V |
// _______________________ |
// | FindDuplicateHashes | |
// """"""""""""""""""""""" |
// | |
// | V
// duplicate chunk locations inputFile buffer
// | |
// V |
// _____________________ |
// | CompileCandidates | |
// """"""""""""""""""""" |
// | |
// | |
// candidate duplicates |
// | |
// V |
// ______________ |
// | WriteIndex | <----------------------/
// """"""""""""""
// |
// V
// ~~~~~~~~~~~~~~
// { outputFile }
// ~~~~~~~~~~~~~~
//
func CreateIndex(
inputFilePath string,
outputFilePath string,
guaranteedDuplicateLength uint64,
maxMatchesPerDupe int,
) error {
// Open the input file and a buffered reader on top for CalcRollingHashes()
inputFile, err := os.Open(inputFilePath)
if err != nil {
return fmt.Errorf("unable to open input file: %w", err)
}
defer inputFile.Close()
bufferedInputFile := bufio.NewReader(inputFile)
// Get the file's info
inputFileStats, err := inputFile.Stat()
if err != nil {
return fmt.Errorf("unable to determine size of input file: %w", err)
}
// Open another reference to the input file for WriteIndex()
inputFile1, err := os.Open(inputFilePath)
if err != nil {
return fmt.Errorf("unable to open input file: %w", err)
}
defer inputFile1.Close()
// Likewise open the output file
outputFile, err := os.Create(outputFilePath)
if err != nil {
return fmt.Errorf("unable to create output file: %w", err)
}
defer outputFile.Close()
bufferedOutputFile := bufio.NewWriter(outputFile)
// Write a placeholder header to the output file for alignment purposes
// (we'll come back and set the index end offset later)
err = WriteHeader(bufferedOutputFile, 0)
if err != nil {
return fmt.Errorf("failed writing to output file: %w", err)
}
// Calculate parameters
chunkSize := guaranteedDuplicateLength / 2
inputFileSize := uint64(inputFileStats.Size())
// Create channels and variables for communication between the main goroutines
fatalErr := NewSharedValue[error]()
inputFileBuffer := NewSeekableBuffer(InputFileBufferCap)
hashChan := make(chan uint64, DedupChanCap)
duplicateChunkChan := make(chan ChunkMatches, DedupChanCap)
candidateChan := make(chan AnyCandidate, DedupChanCap)
// Start the goroutine which calculates the rolling hash values
go CalcRollingHashes(
bufferedInputFile, chunkSize, hashChan, &inputFileBuffer, fatalErr,
)
// Start the goroutine which finds chunks with matching hashes
//
// This is the last goroutine which always updates for every byte offset,
// so we use it for our progress percentage.
curByteOffset := NewSharedValue[uint64]()
go FindDuplicateHashes(
hashChan, chunkSize, inputFileSize,
maxMatchesPerDupe, duplicateChunkChan, curByteOffset,
)
// Start the goroutine which produces an initial set of candidate duplicates
go CompileCandidatesMultithreaded(
duplicateChunkChan, chunkSize, candidateChan,
)
// Start the goroutine which refines these candidates and actually writes
// the insertion index
//
// This is the goroutine which reports deduplication statistics.
dedupStats := NewSharedValue[DeduplicationStats]()
go WriteIndex(
candidateChan, &inputFileBuffer, inputFile1, chunkSize,
inputFileSize, bufferedOutputFile, dedupStats, fatalErr,
)
// Print the current progress every half-second and otherwise wait until
// the insertion index is complete
startTime := time.Now()
indexIsFinished := false
for !indexIsFinished {
// Terminate if there's been an error
if fatalErr.Get() != nil {
// An error has occurred, so terminate
return fatalErr.Get()
}
// Otherwise print current progress
stats := dedupStats.Get()
indexIsFinished = stats.IndexComplete
progress := float64(curByteOffset.Get()) / float64(inputFileSize)
if indexIsFinished {
progress = 1
}
fmt.Printf("\rProgress:%7.3f%%", 100*progress)
if stats.SizeReduction == 0 {
fmt.Print(" Deduped: ? KiB")
} else if stats.SizeReduction < 100*MiB {
fmt.Printf(" Deduped:%5d KiB", stats.SizeReduction/1024)
} else {
fmt.Printf(" Deduped:%5d MiB", stats.SizeReduction/MiB)
}
tElpsd := time.Since(startTime)
fmt.Printf(
" Time Elapsed:%2d:%02d:%02d",
int(tElpsd.Hours()),
int(tElpsd.Minutes())%60,
int(tElpsd.Seconds())%60,
)
fmt.Printf(
" Average Speed: %.2fMB/s",
1e-6*float64(curByteOffset.Get())/tElpsd.Seconds(),
)
if DebugMode {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
fmt.Printf(" %5d", len(hashChan))
fmt.Printf(" %5d", len(duplicateChunkChan))
fmt.Printf(" %5d", len(candidateChan))
fmt.Printf(" %12d", ms.Alloc)
fmt.Printf(" %12d", ms.Sys)
fmt.Printf(" %d", ms.NumGC)
fmt.Printf(" %4.2f", ms.GCCPUFraction)
}
time.Sleep(time.Second / 2)
}
// Finish up the index part of the output file
err = bufferedOutputFile.Flush()
if err != nil {
return fmt.Errorf("failed writing to output file: %w", err)
}
// Get the output file's current info
outputFileStats, err := outputFile.Stat()
if err != nil {
return fmt.Errorf("unable to determine size of index file: %w", err)
}
// Write the offset of the end of the index into the headers
outputFileSize := outputFileStats.Size()
_, err = outputFile.Seek(0, io.SeekStart)
if err != nil {
return fmt.Errorf("failed writing output file's headers: %w", err)
}
err = WriteHeader(outputFile, uint64(outputFileSize))
if err != nil {
return fmt.Errorf("failed writing output file's headers: %w", err)
}
// Report success
return nil
}
// A goroutine which rolls a window of the specified length (in bytes) over the
// reader object passed to it and pipes rolling hash values for every byte offset
// through hashChannel.
//
// The first value on hashChannel is the hash of the first windowSize bytes
// returned by the reader, with every subsequent value corresponding to a window
// advanced by one byte. hashChannel is closed when the end of the file is
// reached.
//
// The raw bytes received from the reader are also pushed to the SeekableBuffer
// inputFileBuffer. A hash value will not be sent onto hashChannel until at
// least the windowSize bytes following the end of the window to which it
// correponds have been pushed to inputFileBuffer.
//
// In the event of a fatal error, this routine sets the value of the fatalErr
// argument to that error, kills all child goroutines, closes hashChannel, then
// returns. Likewise, it checks if fatalErr is non-nil every time a value is
// sent onto outChan and terminates in the same way if it is.
func CalcRollingHashes(
reader io.ByteReader,
windowSize uint64,
hashChannel chan uint64,
inputFileBuffer *SeekableBuffer,
fatalErr SharedValue[error],
) {
// Set up a buffer for the bytes from the input file, so that we can push
// to inputFileBuffer in batches
const minBatchSize = 8192
batchSize := Max(minBatchSize, windowSize)
bufSize := 3 * batchSize
buf := make([]byte, bufSize)
// Start the hashing goroutine
byteChan := make(chan byte, DedupChanCap)
go CalcAllHashes(byteChan, hashChannel, uint(windowSize))
// Read bytes into the buffer one at a time
for curByteOffset := uint64(0); true; curByteOffset++ {
// Read the byte into the buffer
var err error
buf[curByteOffset%bufSize], err = reader.ReadByte()
if err == io.EOF || err == io.ErrUnexpectedEOF {
// If we've reached the end of the file, push any remaining bytes
// to inputFileBuffer, ...
batchStart := batchSize * (curByteOffset / batchSize)
inputFileBuffer.Push(buf[batchStart%bufSize : curByteOffset%bufSize])
// ... send them (and any not sent with the last batch) onto
// byteChan, ...
off := PositiveDiff(batchStart, windowSize)
for ; off < curByteOffset; off++ {
byteChan <- buf[(off % bufSize)]
}
// ... then close the channel and inputFileBuffer and return.
close(byteChan)
inputFileBuffer.Close()
return
} else if err != nil {
fatalErr.Set(fmt.Errorf("failed reading from input file: %w", err))
close(byteChan)
return
}
// Check if a fatal error has occured and respond accordingly if so
if fatalErr.Get() != nil {
close(byteChan)
return
}
// If this is the start of a new batch, push the bytes from the previous
// batch to inputFileBuffer, and likewise send bytes to byteChan but on
// a delay of windowSize (so that the following windowSize bytes have
// been pushed to inputFileBuffer).
if (curByteOffset%batchSize == 0) && (curByteOffset >= batchSize) {
batchStart := curByteOffset - batchSize
bufOff := batchStart % bufSize
inputFileBuffer.Push(buf[bufOff : bufOff+batchSize])
off := PositiveDiff(batchStart, windowSize)
for ; off < curByteOffset-windowSize; off++ {
byteChan <- buf[(off % bufSize)]
}
}
}
}
// A goroutine which takes a stream of hash values from CalcRollingHashes
// and identifies any which match a previously received value. Such matches
// are sent as ChunkMatches objects onto duplicateChunkChan.
//
// Specifically, byte offsets which are multiples of chunkSize are stored in a
// hash table under their corresponding hash value. If the same hash value is
// received again, it will be identified as a duplicate.
//
// When there are multiple possible matches for a duplicate chunk, only the
// most recent of maxMatchesPerDupe of them will be kept.
//
// currentOffset is kept updated with the latest offset for which the hash
// value has been processed.
//
// chunkSize and inputFileSize are both in bytes.
//
// When hashChannel is closed and all received hashes have been processed,
// duplicateChunkChan is closed.
func FindDuplicateHashes(
hashChannel chan uint64,
chunkSize uint64,
inputFileSize uint64,
maxMatchesPerDupe int,
duplicateChunkChan chan ChunkMatches,
currentOffset SharedValue[uint64],
) {
// For performance reasons, hash duplicates are found in parallel.
//
// One goroutine receives hash values from hashChannel and, based on the
// value of the hash modulo NumParallelHashTables (so that the same
// hash always goes to the same place), sends them to one of a number of
// goroutines running GetMatchOffsets(). Note that we must use the least
// significant bits to allocate them to hash tables, since the table lookups
// are based on the most significant bits. Another goroutine receives the
// resulting matches in the correct order and forwards them to matchChannel.
//
// So that the latter goroutine can receive the matches in the correct
// order, it is sent the sequence of channels to receive from (specifically,
// their indices) by the former goroutine via the channel hashTableNumPassThrough.
// Calculate the total number of hash table entries we expect
totalNumChunks := inputFileSize / chunkSize
// Set up goroutines running GetMatchOffsets() and the required channels
internalHashChans := make([]chan [2]uint64, NumParallelHashTables)
internalMatchChans := make([]chan ChunkMatches, NumParallelHashTables)
hashTableNumPassThrough := make(chan uint64, 2*DedupChanCap*NumParallelHashTables)
for i := uint64(0); i < NumParallelHashTables; i++ {
internalHashChans[i] = make(chan [2]uint64, DedupChanCap)
internalMatchChans[i] = make(chan ChunkMatches, DedupChanCap)
go GetMatchOffsets(
internalHashChans[i],
chunkSize,
totalNumChunks/NumParallelHashTables,
maxMatchesPerDupe,
internalMatchChans[i],
)
}
// Start a goroutine to forward hashes from hashChannel to the appropriate
// element in internalHashChans
go func() {
for curByteOffset := uint64(0); true; curByteOffset++ {
hash, ok := <-hashChannel
if !ok {
for _, ch := range internalHashChans {
close(ch)
}
close(hashTableNumPassThrough)
return
}
i := hash % NumParallelHashTables
internalHashChans[i] <- [2]uint64{curByteOffset, hash}
hashTableNumPassThrough <- i
}
}()
// In the current goroutine, receive matches in the correct order and forward
// them to matchChannel (though don't waste time sending those containing
// no matches).
for curByteOffset := uint64(0); true; curByteOffset++ {
i, ok := <-hashTableNumPassThrough
if !ok {
close(duplicateChunkChan)
return
}
matchCandObj := <-internalMatchChans[i]
if len(matchCandObj.MatchOffs) > 0 {
duplicateChunkChan <- matchCandObj
}
currentOffset.Set(curByteOffset)
}
}
// Continuously receive hash values from inChan and send corresponding
// ChunkMatches objects to matchChannel containing any previous offsets
// at which the same hash was received.
//
// Each input via inChan consists of two values. The first should contain the
// byte offset of the start of the window over which the hash was calculated,
// and the second should contain the value of the hash.
//
// When the offset is divisible by chunkSize, a record is made of it and the
// corresponding hash. If and when the same hash is received again, the
// match object send on matchChannel will contain this (and any other)
// corresponding byte offset in the MatchOffs slice. An object will still
// be sent to matchChannel for Non-duplicate hashes, but it will have an empty
// MatchOffs slice.
//
// When inChan is closed and all received hashes have been processed,
// matchChannel is closed.
//
// When there are multiple possible matches for a duplicate chunk, only the
// most recent maxMatchesPerDupe of them will be kept.
//
// Assumes that the received offsets are monotonically increasing.
//
// For memory allocation purposes, expectedNumEntries specifies the expected
// number of offsets which will need to be recorded, ie it should be the
// expected number of objects received from inChan divided by chunkSize.
func GetMatchOffsets(
inChan chan [2]uint64,
chunkSize uint64,
expectedNumEntries uint64,
maxMatchesPerDupe int,
matchChannel chan ChunkMatches,
) {
// Initialise a hash table which will map the hash of each chunk to the byte
// offset of the start of the chunk
chunkHashTable := NewOptimisedHashTable(
expectedNumEntries,
maxMatchesPerDupe,
)
// Process the inputs from inChan one at a time
for {
inp, ok := <-inChan
if !ok {
// The hash channel has been closed, so close matchChannel
// and return
close(matchChannel)
return
}
curByteOffset, currentHash := inp[0], inp[1]
// Get the byte offsets of all chunks with the same hash as the
// current one and output them through matchChannel
matchObj := ChunkMatches{
Offset: curByteOffset,
MatchOffs: chunkHashTable.GetAllValues(currentHash),
}
matchChannel <- matchObj
// If the hash window is currently aligned with a chunk, add the current
// hash to the hash table
if (curByteOffset % chunkSize) == 0 {
chunkHashTable.Insert(currentHash, curByteOffset)
}
}
}
// A goroutine which takes a stream of ChunkMatches objects (from
// FindDuplicateHashes), and processses them into a shortlist of candidates
// for the duplicates which will actually be written to the index.
//
// This routine only works with whole matching chunks, so in order to determine
// the optimal set of duplicates, subsequent steps will need determine their
// full extent by directly checking the bytes immediately before and after each
// duplicate one-by-one.
//
// These incomplete candidates are sent on outChan as objects implementing
// AnyCandidate (either as a DupeIndexEntry, a CandidateDupe or a
// RepeatingCandidateDupe). They are sent in ascending order of DupeOffset.
//
// When inChan is closed and all received chunk matches have been processed,
// outChan is closed.
func CompileCandidatesMultithreaded(
inChan chan ChunkMatches,
chunkSize uint64,
outChan chan AnyCandidate,
) {
// We run several CompileCandidates() threads in parallel for speed.
// To do this, we split the file into sections (of length
// chunkSize*sectionSizeMult bytes), and send the chunkMatches referring
// to each section to a different goroutine (in round-robin fashion).
sectionSize := chunkSize * CompCandsSectionSizeMult
const numThreads = CompCandsNumThreads
// In order to correctly handle duplicates occurring on the boundaries,
// there needs to be an overlap region where matches are sent to both
// goroutines. For this and other reasons, there will be some duplication
// of effort at the boundaries, but for large sectionSizeMult values
// this should be negligible.
overlapLen := Max(chunkSize, MinDuplicateLength)
// To prevent deadlocks, we need to ensure that chunkMatchBuf spans at
// least three sections, so that the current and next section can both be
// finished and all candidates therefrom sent based purely on the channel's
// buffer.
chunkMatchBufSize := 2*(sectionSize+overlapLen) + chunkSize
// Set up goroutines running CompileCandidates() and the required channels
internalChunkMatchChans := make([]chan ChunkMatches, numThreads)
internalCandidateChans := make([]PeekableChan[AnyCandidate], numThreads)
for i := uint64(0); i < numThreads; i++ {
internalChunkMatchChans[i] = make(chan ChunkMatches, chunkMatchBufSize)
candChan := make(chan AnyCandidate, CompCandsCandidateBufSize)
internalCandidateChans[i] = MakePeekable(candChan)
go CompileCandidates(
internalChunkMatchChans[i], candChan, chunkSize,
)
}
// Start a thread which assigns incoming chunkMatches objects to the
// appropriate thread
go func() {
sectionNum := uint64(0)
startOfNextSection := sectionSize
startOfOverlap := startOfNextSection - overlapLen
for {
matches, ok := <-inChan
if !ok {
// inChan is closed so finish up
for _, ch := range internalChunkMatchChans {
close(ch)
}
return
}
// Move on to the next section if appropriate
if matches.Offset >= startOfNextSection {
sectionNum = matches.Offset / sectionSize
startOfNextSection = (sectionNum + 1) * sectionSize
startOfOverlap = startOfNextSection - overlapLen
}
// Send matches to the correct channel for this section.
//
// If we're in the overlap region, also send a copy to the channel
// for the next section.
if matches.Offset >= startOfOverlap {
matchesCopy := ChunkMatches{
Offset: matches.Offset,
MatchOffs: make([]uint64, len(matches.MatchOffs)),
}
copy(matchesCopy.MatchOffs, matches.MatchOffs)
internalChunkMatchChans[(sectionNum+1)%numThreads] <- matchesCopy
}
internalChunkMatchChans[sectionNum%numThreads] <- matches
}
}()
// In the current goroutine, forward candidates to outChan in the correct
// order (ie sorted by DupeOffset ascending).
for {
// Find out which of the candidate channels is next (ie will receive
// the candidate with the smallest DupeOffset value)
var minIdx int
minDupeOffset := uint64(math.MaxUint64)
anyOk := false
for i, candChan := range internalCandidateChans {
cand, ok := candChan.Peek()
if ok {
anyOk = true
if cand.DupeOff() <= minDupeOffset {
minIdx = i
minDupeOffset = cand.DupeOff()
}
}
}
if !anyOk {
// All of the threads have terminated, so finish up
close(outChan)
return
}
// Forward the appropriate candidate to outChan
cand, _ := internalCandidateChans[minIdx].Receive()
outChan <- cand
}
}
// A goroutine which takes a stream of ChunkMatches objects (from
// FindDuplicateHashes), and processses them into a shortlist of candidates
// for the duplicates which will actually be written to the index.
//
// This routine only works with whole matching chunks, so in order to determine
// the optimal set of duplicates, subsequent steps will need determine their full
// extent by directly checking the bytes immediately before and after each
// duplicate one-by-one.
//
// These incomplete candidates are sent on outChan as objects implementing
// AnyCandidate (either as a DupeIndexEntry, a CandidateDupe or a
// RepeatingCandidateDupe). They are sent in ascending order of DupeOffset.
//
// When inChan is closed and all received chunk matches have been processed,
// outChan is closed.
func CompileCandidates(
inChan chan ChunkMatches,
outChan chan AnyCandidate,
chunkSize uint64,
) {
// Duplicates may run for longer than chunkSize, so we'll need to hang on
// to them until we can be sure that we've reached then end of them, so
// initialise a slice of CandidateDupe objects. Since duplicates will
// progress 'chunkwise', we use a 2-dimensional slice, with the index
// in the first dimension representing their DupeOffset modulo chunkSize.
currentCandidates := make([][]CandidateDupe, chunkSize)
// To ensure we send on outChan in the correct order, we need to keep
// track of the position of the earliest candidate still pending (ie the
// smallest DupeOffset value in currentCandidates). When there are
// none, this is set to math.MaxUint64. It is set to 0 to indicate that
// it needs to be recalculated.
currentCandsEarliestDupeOffset := uint64(0)
// The usual algorithm often slows to a crawl when faced with a short
// pattern of bytes which repeats over a length of more than chunkSize
// (among other things, files will frequently contain long runs of zeros),
// so we treat these as a special case. Therefore create a slice analagous
// to that above (though in this case one-dimensional) for
// RepeatingCandidateDupe objects.
//
// For performance reasons, currentRepeatingCands should be sorted by
// MatchOffset (ascending).
currentRepeatingCands := make([]RepeatingCandidateDupe, 0)
// Once we've reached the end of a candidate, there is still a possibility
// that another candidate may subsequently surpass it. Since we don't want
// to waste time in the next step by doing unnecessary byte-by-byte
// comparisons, we'll hold on to candidates in this list until we can
// compare them with all potentially competing candidates.
//
// This should be ordered by DupeOffset, since that is the order they are
// sent to outChan.
//
// TODO: The process of inserting into this tree is the main slowdown when
// there are lots of duplicates. Its agressive rebalancing algorithm
// is good for the FileCache in WriteIndex(), which spends almost all
// of its time Search()ing, but here it would be better to use a more
// relaxed version.
var finishedCands BinaryTree[AnyCandidate]
// In order to eliminate the candidates which are definitely suboptimal,
// we keep track of what the optimal set would be assuming that only the
// whole chunks we already have match. We can then compare any candidates
// against this, and any that can't offer any improvement (even assuming
// that they become as long as possible after a byte-by-byte comparison
// in the next step) can be safely discarded.
currentBestDupes := DupeIndex{}
// Other variables which need to persist between loop iterations, documented
// at the locations they are used:
var endOfLastFinishedEntry uint64
var lastSentDupeOffset uint64
// Then loop continuously, processing the file one byte at a time
newChunkMatches, inChanOk := <-inChan
for curByteOffset := uint64(0); true; curByteOffset++ {
// If possible, run the version of this loop optimised for a purely
// repeating section of the file.
// Note that CompileCandidates() would work just as well with this
// ommitted, but just slower.
CompleteRepeatingCandidates(
&curByteOffset,
inChan,
&inChanOk,
&newChunkMatches,
currentCandidates,
¤tRepeatingCands,
¤tBestDupes,
chunkSize,
)
if !inChanOk {
// The candidate channel is closed and empty, so we've reached
// the end of the file. Check if there are still any pending
// candidates.
finished := ((len(currentRepeatingCands) == 0) &&
(finishedCands.Size == 0) && currentBestDupes.IsEmpty())
for _, s := range currentCandidates {
if len(s) != 0 {
finished = false
break
}
}
if finished {
// We've finished processing all potential duplicates, so
// close outChan and return
close(outChan)
return
}
}
// If we have reached the offset corresponding to the last set
// of chunk matches we received from the channel, use them to update
// currentCandidates and currentRepeatingCands, then receive the next set
if curByteOffset == newChunkMatches.Offset {
ExtendCurrentCandidates(
newChunkMatches,
currentCandidates,
¤tRepeatingCands,
¤tBestDupes,
¤tCandsEarliestDupeOffset,
chunkSize,
)
// Get the parameters of the next hash match from FindDuplicateHashes()
// (if the channel isn't already closed)
if inChanOk {
newChunkMatches, inChanOk = <-inChan
} else {
newChunkMatches = ChunkMatches{}
}
}
currentCandsSubSlice := ¤tCandidates[curByteOffset%chunkSize]
// Handle any in-progress repeating candidates which are now definitely
// finished (ie those which matched patternLength or more ago but didn't
// match this time).
//
// When removing finished candidates from currentRepeatingCands, set
// a boolean flag so that they can be removed in one go once we're done.
candIsFinished := make([]bool, len(currentRepeatingCands))
for candIndex := range currentRepeatingCands {
rptngCand := currentRepeatingCands[candIndex]
dupeEnd := rptngCand.DupeEnd()
if (curByteOffset + chunkSize) >= (dupeEnd + rptngCand.PatternLength) {
// This candidate is finished, so add it to finishedCands
// ready to (possibly) send on outChan if it has a chance of
// being added to the index.
if currentBestDupes.MightBeImprovedBy(&rptngCand, chunkSize) {
finishedCands.OrderedInsert(
&rptngCand,
func(c AnyCandidate) uint64 { return c.DupeOff() },
)
}
// Update the currentBestDupes with it
currentBestDupes.UpdateWith(&rptngCand)
// Remove it from currentRepeatingCands
candIsFinished[candIndex] = true
// To ensure optimality we also need to account for the
// possibility that the duplicate continues for a fairly long
// region following the end of the repeating section. If we
// don't account for this explicitly, the duplicate will end up
// split into two, which may mean that its full length is
// never recognised and it is ignored despite being optimal.
//
// Therefore, if there is a possibility of the duplicate
// continuing beyond the end of the repeating region, create
// all possible ordinary candidates which may subsequently
// continue it, and add them to currentCandidates.
isSelfMatch := (rptngCand.MatchEnd() > rptngCand.DupeOffset)
if !isSelfMatch {
// Create a candidate for every possible alignment of
// DupeEnd() relative to the chunk boundaries for which
// the bytes still match.
//
// If the ordinary candidate was to end before
// rptngCand.DupeEnd()-chunkSize, the only way it could be
// extended is by matching a chunk which we know consists
// of a repeating pattern which is already described by
// rptngCand, so we only need to create candidates which
// end between rptngCand.DupeEnd()-chunkSize and
// rptngCand.DupeEnd().
endPatternOffDiff := ModuloDiff(
rptngCand.DupeLength,
rptngCand.MatchLength,
rptngCand.PatternLength,
)
candDupeEnd := rptngCand.DupeEnd() - endPatternOffDiff
for ; candDupeEnd+chunkSize > rptngCand.DupeEnd(); candDupeEnd -= rptngCand.PatternLength {
candLen := Min(
candDupeEnd-rptngCand.DupeOffset,
rptngCand.MatchLength,
)
// (ordinary candidates at this stage should only be
// based on whole matching chunks.)
candLen -= candLen % chunkSize
if candLen > 0 {
cand := CandidateDupe{
DupeOffset: candDupeEnd - candLen,
MatchOffset: rptngCand.MatchEnd() - candLen,
Length: candLen,
}
i := cand.DupeOffset % chunkSize
currentCandidates[i] = append(currentCandidates[i], cand)
if cand.DupeOffset < currentCandsEarliestDupeOffset {
currentCandsEarliestDupeOffset = cand.DupeOffset
}
}
}
}
}
}
// Now actually remove the finished candidates
OrderedDelete(¤tRepeatingCands, candIsFinished)
// Handle any in-progress candidates which are now definitely finished
// (ie those which matched one chunk ago but didn't match this time)
for candIndex := 0; candIndex < len(*currentCandsSubSlice); candIndex++ {
cand := (*currentCandsSubSlice)[candIndex]
if cand.DupeEnd() <= curByteOffset {
// We've reached the end of this candidate, so update the
// currentBestDupes accordingly and add it to finishedCands,
// then delete it from currentCandidates.
currentBestDupes.UpdateWith(&cand)
if currentBestDupes.MightBeImprovedBy(&cand, chunkSize) {
finishedCands.OrderedInsert(
&cand,
func(c AnyCandidate) uint64 { return c.DupeOff() },
)
}
UnorderedDelete(currentCandsSubSlice, candIndex)
if cand.DupeOffset == currentCandsEarliestDupeOffset {
currentCandsEarliestDupeOffset = 0
}
// Make sure we don't skip the next candidate
candIndex--
}
}
// Send onto outChan as appropriate. We send elements from finishedCands,
// but only once we have enough information to determine whether they
// stand a chance of being optimal.
{
// Recalculate currentCandsEarliestDupeOffset if we need to
if currentCandsEarliestDupeOffset == 0 {
currentCandsEarliestDupeOffset = FuncMin(
len(currentCandidates),
func(j int) uint64 {
return FuncMin(
len(currentCandidates[j]),
func(i int) uint64 {
return currentCandidates[j][i].DupeOffset
},
math.MaxUint64,
)
},
math.MaxUint64,
)
}
// Calculate the value of finishedOffset.
// This is the earliest start offset of the duplicate part of any
// current (or potential future) candidate or repeating candidate.
//
// We can be sure that we've already found all dupes with duplicate
// parts which start before finishedOffset, so such dupes can be
// sent onto outChan without any risk they'll be sent out of order.
earliestNewCandStart := curByteOffset
earliestCurrentCand := currentCandsEarliestDupeOffset
earliestCurRptngCand := FuncMin(
len(currentRepeatingCands),
func(i int) uint64 { return currentRepeatingCands[i].DupeOffset },
math.MaxUint64,
)
finishedOffset := Min(earliestNewCandStart, earliestCurrentCand, earliestCurRptngCand)
// The MightImprove() methods of candidates return false if the
// candidate is tied with the duplicates already in currentBestDupes,
// and there are edge cases where candidates can't do better than
// tie with one of them. No candidates corresponding to this
// duplicate will therefore be sent onto outChan, leading to a
// suboptimal outcome. Therefore we must treat the entries in
// currentBestDupes as an additional source of candidates.
for {
entry, exists := currentBestDupes.FirstEntryEndingAfter(endOfLastFinishedEntry)
if exists && (entry.DupeEnd() < curByteOffset) {
endOfLastFinishedEntry = entry.DupeEnd()
// (entrySlice creates a copy somewhere else in memory
// for the AnyCandidate pointer to point to)
entrySlice := []DupeIndexEntry{entry}
finishedCands.OrderedInsert(
&entrySlice[0],
func(c AnyCandidate) uint64 { return c.DupeOff() },
)
} else {
break
}
}
// There may at this stage be entries in currentBestDupes which end
// at or after curByteOffset (so are not yet in finishedCands), but
// which start earlier than some of the candidates in finishedCands.
// For the output to be properly ordered by DupeOffset, we must wait
// until these entries have been added to finishedCands before we
// can safely send those candidates.
firstUnfinishedEntry, fUEExists := currentBestDupes.FirstEntryEndingAfter(
endOfLastFinishedEntry,
)
var startOfUnfinishedEntries uint64
if fUEExists {
startOfUnfinishedEntries = firstUnfinishedEntry.DupeOffset
} else {
startOfUnfinishedEntries = math.MaxUint64
}
// Send finished candidates onto outChan in order until we reach
// one which we can't yet send for some reason.
finishedCandIdx := 0
for ; finishedCandIdx < finishedCands.Size; finishedCandIdx++ {
cand := *finishedCands.Get(finishedCandIdx)
if cand.DupeEnd()+MinDuplicateLength > curByteOffset {
// At this stage, there are rare edge cases where the
// MightImprove() methods will return true with the candidate
// now, but won't once we've fleshed out some of the other
// candidates a bit more, so we can't send this candidate
// just yet.
break
}
if cand.DupeOff() > finishedOffset {
// This candidate has a duplicate part which might start after
// candidates which are yet to be added to finishedCands,
// so we can't send it yet.
break