Back-End

10 set, 2012

Redescobrindo o algoritmo RSync

Publicidade

A: Ok, você está sincronizando isso por toda a Internet; e o que usa você para fazer a sincronização?

B: Oh, nós implementamos o algoritmo rsync.

A: Aham. E o que você faz com os arquivos que são muito grandes?

B: A mesma coisa.

A: E você também sincroniza pastas?

B: Sim.

A: E como você faz isso?

B: Nós fazemos uma iteração nas pastas, usando o algoritmo em cada  arquivo, recursivamente nas subpastas.

A: Você pode tentar duas coisas para mim? Primeiro, um arquivo muito grande; e segundo, uma base de código grande, e veja se ele aguenta.

Introdução

Em primeiro lugar, eu sou um admirador do algoritmo (original) rsync. Eu acho que ele foi uma primeira punhalada na sincronização de arquivo, e era tão bom que as pessoas ainda o implementam hoje.

Mas, se você não entende suas forças e fraquezas, você pode acabar se decepcionando.

O problema

Você possui dois arquivos, A’ e A que são diferentes, porém semelhantes. Eles residem em dois locais diferentes conectados por uma rede. Você quer fazer o A’ idêntico ao A.

A solução mais simples é só copiar A, mas dada a semelhança entre os dois arquivos, tem que haver um jeito melhor.

Interlúdio histórico

As redes costumavam ser notoriamente ruins no início dos anos 90. Todo mundo que estava transferindo arquivos a grandes distâncias instintivamente sabia sobre um download de tamanho crítico.

Levaria muito tempo se o arquivo fosse muito grande, produzindo uma chance de 100% de algo dar errado em algum lugar, resultando em um download interrompido. Mesmo que o download tivesse sucesso, as chances eram de uma pequena parte do arquivo ser corrompido, e você tinha que começar tudo de novo. Os dois primeiros alívios para esse problema foram as checksums para detectar danos acidentais, e as retomadas (ser capaz de iniciar um download em um determinado ponto).

O Rsync cuidou de downloads interrompidos e também proporcionou uma solução melhor quando o arquivo estava corrompido. Além disso, permitiu a propagação de baixo custo de pequenas mudanças, abrindo um novo leque de aplicações. Os administradores de sistema tinham uma nova arma.

A estratégia RSync

O Rsync faz uma única viagem. Primeiro ele cria uma assinatura de A ‘ e a envia. Em outro lugar, ele verifica o arquivo local, tenta encontrar as partes que estão na assinatura enquanto constrói uma receita como uma sequência de instruções. É possível derivar o algoritmo a partir de uma versão primitiva, melhorando-o passo a passo.

Já que é muito divertido, vou fazer isso aqui. Enquanto estamos brincando, vamos abstrair o IO, porque atrapalha a visão algoritmica.

Marco 0

Vamos atacar isso no puro estilo homem das cavernas. Fazer uma assinatura é dividir o arquivo em blocos de mesmo tamanho (exceto talvez o último). Iterando sobre os blocos, você calcula uma compilação e acumula resumos e identificadores de blocos. Identificadores de blocos são apenas seus número: o primeiro bloco tem id 0, o segundo bloco id 1, e assim por diante.

let file_signature f b_size = 
  let f_len = String.length f in
  let rec loop acc s i =
    if s = f_len 
    then acc
    else 
      let b_len = min b_size (f_len - s) in
      let b = String.sub f s b_len in
      let b_sig = block_signature b in
      let acc' = (b_sig,i) :: acc in
      loop acc' (s+b_len) (i+1)
  in
  loop [] 0 0

 

Temos muitas opções de escolha para calcular uma assinatura de bloco. Vamos dar uma de preguiçosos e pegar Digest.string, que é a soma de verificação md5 do bloco.

let block_signature block = Digest.string block

Para recriar o arquivo, você precisará interpretar o fluxo de instruções. Mas o que seriam essas instruções?

Bem, nesta versão, pode ser sido dito a você para copiar um bloco ou escrever um char.

type instruction = 
  | C  of char
  | B  of int

Ok, como você combina a assinatura junto com o novo arquivo para gerar um fluxo de instruções? A primeira coisa que vem à mente é varrer o novo arquivo, iniciando na posição s.

  • Considere o bloco começando em s e tente encontrá-lo na assinatura.
  • Se encontrá-lo, adicione uma instrução B j, e pule um bloco para frente.
  • Se perder isso, adicione uma instrução C c, e avance uma posição para frente.

Vamos fazer isto:

let updates f0_sig b_size f1 = 
  let f1_len = String.length f1 in
  let rec loop acc s = 
    if s = f1_len 
    then List.rev acc
    else
      let b_len = min b_size (f1_len - s) in
      let block = String.sub f1 s b_len in
      let u,step = 
        try 
          let d = block_signature block in
          let i = List.assoc d f0_sig in 
          (B i), b_len
        with Not_found -> (C block.[0]), 1
      in
      let acc' = u :: acc in
      loop acc' (s + step)
  in
  loop [] 0

 

O último passo no nosso esquema de sincronização é criar um arquivo usando o antigo, juntamente com o fluxo de instruções.

let apply old b_size ins =
  let old_len = String.length old in
  let buf = Buffer.create old_len in
  let add_block i = 
    let s = i * b_size in
    let b_len = min b_size (old_len - s) in
    let block = String.sub old s b_len in
    Buffer.add_string buf block
  in
  let add_char c = Buffer.add_char buf c in
  let () = List.iter (function 
    | B i -> add_block i
    | C c -> add_char c) 
    ins
  in
  Buffer.contents buf

 

Então foram precisas 60 linhas de código para ter uma primeira tentativa de um algoritmo de sincronização.

Vamos tentar isso em um exemplo:

let bs = 5
let remote = "aaaaabbbbbcccccdddddeeeeefffffggggghhhhhiiiiijjjjjkkk"
let mine = "aaaaabXbbbcccccddddde012"
let mine_s = file_signature mine bs

let delta = updates mine_s bs remote
let mine2 = apply mine bs delta;;

val bs : int = 5
val remote : string = "aaaaabbbbbcccccdddddeeeeefffffggggghhhhhiiiiijjjjjkkk"
val mine : string = "aaaaabXbbbcccccddddde012"

val mine_s : (Digest.t * int) list =
[("$\240\t\221\19200\222\199\2035\190|\222~#\n", 4);
("P\248M\175:m\253j\159 \201\248\239B\137B", 3);
("g\199b'k\206\208\158\228\22314\2137\209d\234", 2);
("\196\148\"\21926Lm\179V E=\201O\183,", 1);
("YO\128;8\nA9n\214=\2029P5B", 0)]

val delta : instruction list =
[B 0; C 'b'; C 'b'; C 'b'; C 'b'; C 'b'; B 2; B 3; C 'e'; C 'e'; C 'e';
C 'e'; C 'e'; C 'f'; C 'f'; C 'f'; C 'f'; C 'f'; C 'g'; C 'g'; C 'g';
C 'g'; C 'g'; C 'h'; C 'h'; C 'h'; C 'h'; C 'h'; C 'i'; C 'i'; C 'i';
C 'i'; C 'i'; C 'j'; C 'j'; C 'j'; C 'j'; C 'j'; C 'k'; C 'k'; C 'k']
val mine2 : string = "aaaaabbbbbcccccdddddeeeeefffffggggghhhhhiiiiijjjjjkkk"

 

Ok, ele funciona, mas quão bom ele é?

Antes que eu possa responder a isso, primeiro uma nota sobre o tamanho do bloco. Há algumas “forças” a serem consideradas:

  • o tamanho do bloco precisa ser grande em comparação com a assinatura dele
  • se o tamanho do bloco for grande, você vai encontrar menos resultados entre a assinatura e o novo arquivo, então você precisa enviar mais dados de volta
  • se o tamanho do bloco for pequeno, você tem muitos deles, ou seja, a sua assinatura será maior e você precisa de uma pesquisa eficiente

O tamanho melhor de bloco dependerá não só do tamanho do arquivo, mas das mudanças reais.

Quão importante é isso mesmo?

Bem, vamos sincronizar duas imagens:

 

Essas duas imagens são bitmaps de 5,5 MB cada (armazenadas como .bmp).
(Eu, na verdade, fiz upload das versões menores, já que parece inútil deixar seu navegador baixar mais de 10 MB por apenas duas amostras das imagens bobas)
Eu vou sincronizá-las usando tamanhos de blocos diferentes e ver no que dá.

let main () =
  let bs = int_of_string (Sys.argv.(1)) in
  let () = Printf.printf "bs=%i\n" bs in
  let remote = get_data "new_image.bmp" in
  let () = Printf.printf "remote: size=%i%!\n" (String.length remote) in
  let mine = get_data "old_image.bmp" in
  let mine_s = X.file_signature mine bs in
  let () = Printf.printf "mine_s: len=%i%!\n" (Array.length mine_s) in
  let delta = X.updates mine_s bs remote in
  let (nbs,ncs) = List.fold_left (fun(bs,cs) i ->
    match i with
      | X.B _ -> (bs+1,cs)
      | X.C _ -> (bs,cs+1)
  ) (0,0) delta 
  in
  let mine2 = X.apply mine bs delta in
  let () = Printf.printf "mine2: size=%i%!\n" (String.length mine2) in
  let () = Printf.printf "bs=%i;cs=%i\n" nbs ncs in
block size # block signatures blocks chars time
8192 704 535 1384448 71s
4096 1407 1084 1323008 92s
2048 2813 2344 960512 92s
1024 5626 4995 646144 115s
512 11251 10309 482304 172s
256 22501 20972 391424 283s
128 45001 42508 319104 537s

A primeira coluna é o tamanho do bloco. A segunda é o número de blocos no arquivo, a terceira é o número de instruções B e a quarta é o número de instruções C.

As últimas colunas são medidas do tempo de execução no meu laptop. O tempo de processamento é o maior problema. Ocaml é rápido, mas não o suficiente para compensar a minha brutal ineficiência. Imagine o que faria com um filme de 4 GB.

Marco 1

O problema é rapidamente revelado: List.assoc não é adequado para listas longas.
Uma melhor solução é usar um array, classificá-lo na assinatura do bloco e usar a busca binária.

let block_signature block = Digest.string block

let file_signature f b_size = 
  let f_len = String.length f in
  let s = ref 0 in
  let n_blocks = (f_len + b_size -1) / b_size in
  Array.init n_blocks 
    (fun i -> 
      let start = !s in
      let b_len = if start + b_size > f_len then f_len - start else b_size in
      let b = String.sub f start b_len in
      let b_sig = block_signature b in
      let () = s := start + b_len in
      (b_sig,i)
    ) 

type instruction = 
  | C  of char
  | B  of int

let updates f0_sig b_size f1 = 
  let my_cmp (s0,i0) (s1,i1) = String.compare s0 s1 in
  let () = Array.sort my_cmp f0_sig in
  let len = Array.length f0_sig in
  let rec lookup b= 
    let rec find min max = 
      let mid = (min + max) / 2 in
      let (ms,mi) = f0_sig.(mid) in
      if ms = b 
      then mi
      else if min > max then raise Not_found
      else if b > ms then find (mid+1) max
      else find min (mid -1)
    in
    find 0 (len -1)
  in
  let f1_len = String.length f1 in
  let rec loop acc s = 
    if s = f1_len 
    then List.rev acc
    else
      let b_len = min b_size (f1_len - s) in
      let block = String.sub f1 s b_len in
      let u,step = 
        try 
          let d = block_signature block in
          let i = lookup d in 
          (B i), b_len
        with Not_found -> (C block.[0]), 1
      in
      let acc' = u :: acc in
      loop acc' (s + step)
  in
  loop [] 0

let apply old b_size ins =
  let old_len = String.length old in
  let buf = Buffer.create old_len in
  let add_block i = 
    let s = i * b_size in
    let b_len = min b_size (old_len - s) in
    let block = String.sub old s b_len in
    Buffer.add_string buf block
  in
  let add_char c = Buffer.add_char buf c in
  let () = List.iter (function 
    | B i -> add_block i
    | C c -> add_char c) 
    ins
  in
block size # block signatures blocks chars time(s)
8192 704 535 1384448 41
4096 1407 1084 1323008 20
2048 2813 2344 960512 7.8
1024 5626 4995 646144 1.9
512 11251 10309 482304 1.1
256 22501 20972 391424 0.8
128 45001 42508 319104 0.9

Uau, isso é completamente inesperado (mas não estamos reclamando). Então, o que aconteceu? Bem, a pesquisa era tão dominante que estava encobrindo todos os outros comportamentos.

Agora com o lookup fora do caminho, outras coisas podem ser observadas. O problema agora é que um ‘engano’ não só faz uma instrução C ser emitida, mas o mais importante, ele faz outro sumário ser calculado. Quanto menos blocos forem encontrados, maior o tempo de processamento.

Marco 2

O custo do engano foi resolvido por Andrew Tridgell por meio da introdução de uma segunda função hash mais fraca.
Ele usou o checksum Adler-32. Isso significa que adler32(buffer[a+1:b+1])= cheap(adler32(buffer[a:b]), o que o torna adequado para o nosso problema aqui. A biblioteca padrão ocaml não possui um módulo Adler32, então eu fiz  um.
É uma implementação naieve na medida em que segue a definição de perto. De fato, a maioria das operações de módulo pode ser evitada fazendo alguma administração extra.
Eu não me incomodei.

module Adler32 = struct
  type t = {mutable a: int; mutable b: int; mutable c: int}

  let padler = 65521

  let make () = {a = 1 ;b = 0; c = 0}

  let from buf offset length = 

    let too_far = offset + length in
    let rec loop a b i= 
      if i = too_far 
      then {a; b; c = length}
      else (* modulo can be lifted with a bit of math *)
        let a' = (a + Char.code(buf.[i])) mod padler in
        let b' = (b + a') mod padler in
        loop a' b' (i+1)
    in
    loop 1 0 offset

  let reset t = t.a <- 1;t.b <- 0; t.c <- 0

  let digest t = (t.b lsl 16) lor t.a 

  let out t c1 = 
    let x1 = Char.code c1 in
    t.a <- (t.a - x1) mod padler;
    t.b <- (t.b - t.c  * x1 -1) mod padler;
    t.c <- t.c - 1

  let rotate t c1 cn = 
    let up x = if x >= 0 then x else x + padler in
    let x1 = Char.code c1 in
    let xn = Char.code cn in
    t.a <- up ((t.a - x1 + xn) mod padler);
    t.b <- let f = (t.c * x1) mod padler in
           let r = (t.b - f + t.a -1) mod padler in 
           up r

  let update t buf offset length = 
    let too_far = offset + length in 
    let rec loop i = 
      if i = too_far then () 
      else
        let x = Char.code buf.[i] in
        let () = t.a <- (t.a + x) mod padler  in
        let () = t.b <- (t.b + t.a) mod padler in
        let () = t.c <- t.c + 1 in
        loop (i +1)
    in
    loop offset

  let string block = 
    let t = from block 0 (String.length block) in
    digest t
end

O Adler32 é muito mais fraco do que o md5 e usá-lo te expõe a colisões. Então, na verdade, ele age como um teste barato que pode produzir falsos positivos. Essa é a razão pela qual o algoritmo rsync mantém ambos: se o Adler32 do buffer for encontrado na assinatura, existe uma segunda verificação para ver se o md5s combina. O fato de às vezes um precisar girar checksum e em outras vezes precisar reinicializar uma parte do buffer, complica o cálculo das atualizações um pouco.

O código é mostrado abaixo.

let updates f0_sig b_size f1 = 
  let my_cmp (wh0,sh0,i0) (wh1, sh1,i1) = compare wh0 wh1 in
  let () = Array.sort my_cmp f0_sig in
  let len = Array.length f0_sig in
  let rec lookup w = 
    let rec find min max = 
      let mid = (min + max) / 2 in
      let (mw, ms,mi) = f0_sig.(mid) in
      if mw = w
      then (ms,mi)
      else if min > max then raise Not_found
      else if w > mw then find (mid+1) max
      else find min (mid -1)
    in
    find 0 (len -1)
  in
  let f1_len = String.length f1 in
  let weak = Adler32.from f1 0 b_size in
  let rec loop acc b e = 
    if b = f1_len 
    then List.rev acc
    else
      let wh = Adler32.digest weak in
      let step_1 () = 
        let bc = f1.[b] in
        let a = C bc in
        let b' = b + 1 in
        if e +1 < f1_len 
        then 
          let e' = e + 1 in
          let ec = f1.[e] in
          let () = Adler32.rotate weak bc ec in
          loop (a:: acc) b' e'
        else
          let e' = e in
          let () = Adler32.out weak bc in
          loop (a:: acc) b' e'
      in
      try
        let (s0,i0) = lookup wh in
        let sh = Digest.substring f1 b (e - b) in
        if s0 = sh 
        then
          let b' = e in
          let e' = min f1_len (e + b_size) in
          let a = B i0 in
          let () = Adler32.reset weak in
          let () = Adler32.update weak f1 b' (e' - b') in
          loop (a :: acc) b' e'
        else step_1 ()
      with Not_found -> step_1 ()
  in
  loop [] 0 b_size

 

O código, de fato, é um pouco mais confuso porque temos mais coisas para controlar, ao mesmo tempo, mesmo assim ainda é muito pequeno. Vamos ver quão bem é o seu desempenho:

block size # block signatures blocks chars time(s)
8192 704 535 1384448 0.9
4096 1407 1084 1332008 0.9
2048 2813 2344 960512 0.8
1024 5626 4995 646114 0.6
512 11251 10309 482304 0.6
256 22501 20401 537600 0.7
128 45001 42508 319104 0.7

 

Isso remove quase completamente o custo de uma falha, pelo menos para as coisas desse tamanho. A escolha do tamanho do bloco afeta, no entanto, a quantidade de dados que você precisa enviar.

Existe um monte de outras coisas que você pode fazer aqui:

  • BlockSizeHeuristic: algo como O(sqrt (file_size))
  • SuperInstructions: fazer instruções para blocos e caracteres consecutivos
  • função Emit: não acumula as atualizações, mas as emite (usando uma função callback)
  • Assinaturas menores: você pode considerar deixar cair alguns bytes do hash forte
  • IO
  • Compactar fluxo de atualização

O último problema que ainda existe é que algumas modificações não são bem tratadas pelo algoritmo (por exemplo um byte alterado em cada bloco).
Talvez você possa tentar um algorítimo melhor.
Existe um monte deles por aí, e vale a pena dar uma conferida. (Antes que saiba que você estará se deparando com trees merkle ou set reconciliation).

De qualquer maneira, eu já ultrapassei a minha cota para este artigo, porém ainda quero falar uma coisa sobre sincronização de pastas.

O próximo problema

Você possui duas trees de arquivos, A ‘e A que são tão diferentes, porém semelhantes. Elas residem em dois locais diferentes ligados por uma rede. Você quer fazer a A’ idêntica a A.

O que não fazer

Não ande a pasta e não faça ‘rsync’ em cada arquivo que você encontrar.
Um pequeno cálculo vai mostrar o quão ruim ele realmente é.
Suponha que você tenha 20 mil arquivos, cada um com 1 KB. Suponha que um rsync te custe por volta de 0.1s
(ler o arquivo, enviar a assinatura, construir o fluxo de atualizações, aplicando-os).
Isso custa a você cerca de 2 mil segundos ou mais do que meia hora. Os administradores do sistema sabem melhor:
eles não hesitariam: “fazer um tar da tree, sincronizar os tars, e extrair o tar sincronizado”.
Suponha que cada uma das ações demore 5 segundos (superestimando), você ainda estará sincronizado em 15 segundos.
Ou talvez você possa tentar unison. É ocaml também, você sabe.

Se divirta!

***

Texto original disponível em http://blog.incubaid.com/2012/02/14/rediscovering-the-rsync-algorithm/