Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RMF-2

Esse é o motor de medição utilizado pelo Redes Móveis Fixas em suas versões Kotlin (Android) e Swift (iOS).

O problema

O RMF utiliza múltiplos servidores, indo desde o speed.cloudflare.com até servidores abertos de PPPs ou universidades do Brasil, que utilizam soluções abertas, como o LibreSpeed e o OpenSpeedTest™️.

Utilizar um único algoritmo, portanto, seria um ponto necessário, mas a dúvida seria: qual utilizar?

Cada serviço de speed test tem o seu, e é difícil atestar o melhor, porque são decisões de design diferentes. Um tende ao realismo e paga em "estabilidade"; outro poda variações, o que passa a sensação de estabilidade entre múltiplos testes, mas poda justamente o resultado real daquele teste.

Por exemplo:

  • Speedtest by Ookla: descarta as amostras das duas pontas, e o que se relata é 30% das mais lentas e 10% das mais rápidas (a Ookla não documenta o método, então isso é o entendimento público, não a palavra deles). Testes seguidos ficam mais parecidos entre si, mas cortar a cauda lenta não é neutro: ele puxa o número pra cima, e puxa mais quanto pior for o link, que é justamente quando o engasgo era o dado.
  • LibreSpeed: a conta mais simples de todas, média cumulativa pura (totLoaded / tempo), sem janela e sem poda. Nada sai do número: o engasgo do meio da fase fica lá até o fim. Isso não faz o resultado pular de um teste para o outro, porque o denominador só cresce; faz o resultado de um teste que tropeçou ficar abaixo do que aquele link sustenta. Simples na conta não quer dizer cru no resultado: ele é o que mais corrige, com overheadCompensationFactor de 1,06 (+6%) por padrão.
  • OpenSpeedTest™️: fica no meio. Esquecimento exponencial aplicado em degraus, e o número final é a média das leituras da última parte da fase. Corrige +4%.

O estimador

Escolhemos o OpenSpeedTest™️ como ponto de partida: ele parecia o mais "meio termo" dos que encontrei, e a licença MIT permite usar a ideia e ajustar o que faltava. O código aqui é reimplementação, não cópia, e o crédito está em "De onde veio a ideia".

O que copiamos dele: o jeito de acompanhar a velocidade durante o teste.

Um teste de dez segundos gera milhares de leituras que não param quietas. A saída ingênua é dividir tudo que chegou pelo tempo que passou, mas aí o começo lento do teste continua puxando o número lá no fim. O OpenSpeedTest resolve isso esquecendo o passado aos poucos: o que aconteceu agora pesa muito, o que aconteceu há dez segundos quase não pesa.

O que mudamos: três coisas.

  1. O esquecimento passou a ser contínuo. No original ele acontece de solavanco, em momentos marcados do teste. Aqui acontece um pouquinho a cada leitura, o que dá a mesma propriedade sem os solavancos e faz a memória ser sempre do mesmo tamanho, do começo ao fim da fase.
  2. O número final deixou de ser a média do fim do teste. Passou a ser o platô: a velocidade que o link conseguiu sustentar durante a fase. Um engasgo nos últimos segundos não decide mais o resultado sozinho.
  3. O ponteiro e o resultado viraram a mesma coisa. No original são dois números diferentes, e o ponteiro varre até zero pra esconder isso. Aqui o ponteiro chega no número que vai ser publicado.

O resto desta seção é a mesma explicação, com as contas.

Acumuladores com esquecimento contínuo

A cada tique (200 ms por padrão) o motor lê o contador de bytes e marca o par (tempo, bytes). A diferença entre duas marcas alimenta dois acumuladores, um de bytes e um de tempo, e os dois envelhecem juntos:

lambda = exp(-dt / tau)          # tau = 1500 ms
ca     = ca * lambda + Δbytes    # acumulador de bytes
ka     = ka * lambda + Δt        # acumulador de tempo
leitura = ca * 8 / (ka/1000) / 1e6   # Mbps

Isso é uma média de vazão ponderada por exp(-idade/tau): o que aconteceu há um segundo pesa quase tudo, o que aconteceu há dez segundos não pesa quase nada.

Duas propriedades que valem mais do que parecem:

  • lambda sai do dt real entre marcas. Se o timer derrapar, se uma plataforma tiquear em 200 ms e a outra em 180, o resultado não muda. É por isso que Android e iOS podem gravar na mesma coluna do mesmo banco.
  • Nunca se calcula a vazão de um pedaço isolado. ca e ka são somas de bytes e de tempo, e só a razão entre elas vira Mbps. Os dois erros clássicos desse tipo de código, a "média das vazões" e a "média ponderada por bytes", são estruturalmente impossíveis aqui.

O número da fase é um percentil da mesma série

O ponteiro na tela mostra a última leitura. O número que fecha a fase é o p80 da mesma série, depois de descartar o começo (warmup + 2*tau, o tempo que a janela de esquecimento ainda leva carregando a rampa do TCP).

Por que não a última leitura: ela é refém dos últimos tau segundos. Um pico de buffer no fim da fase a move +19%. O percentil olha a fase útil inteira e responde a pergunta do platô.

Por que p80 e tau de 1500 ms: varredura de tau × p. É o ponto onde o pior caso é mínimo, e a superfície em volta é plana. Não é ajuste fino, e mexer um pouco não muda quase nada.

Não existe segunda grandeza

Ponteiro e resultado saem da mesma série. Nos últimos dois segundos o ponteiro converge por smoothstep até o percentil que vai ser publicado, então o card fecha com o número que já estava na tela.

Isso apagou um algoritmo anterior inteiro: a EMA do ponteiro, a suavização, os degraus do parcial, a quantização em blocos, o bloco truncado, a poda 30/10, o piso de retenção e a validação cruzada das podas. Metade daquilo existia só pra esconder a costura entre duas grandezas que agora são uma.

O que o motor NÃO faz

  • Não aplica fator de correção de overhead. O número é a razão crua entre os bytes que a aplicação contou e o tempo medido. Vários testes multiplicam o resultado por um fator pra estimar o que passou no fio com cabeçalho de TCP/IP/HTTP junto: o OpenSpeedTest usa 1,04 (+4%) e o LibreSpeed, 1,06 (+6%). É defensável, e é uma escolha diferente da nossa. Quem comparar números entre ferramentas precisa saber disso antes de gritar, porque só aí já são até 6 pontos percentuais de diferença que não vêm da rede.
  • Não poda amostras. Nada de descartar os N% mais lentos ou os M% mais rápidos. A poda 30/10 já esteve neste código e saiu: ela enviesava pra cima, e o viés crescia junto com a instabilidade do link, que é exatamente quando o número precisa ser confiável.
  • Não sabe protocolo nenhum. Cloudflare, LibreSpeed, Ookla e OpenSpeedTest entram por um contador de bytes só. Isso não faz os quatro darem o mesmo número, porque capacidade do servidor, número de conexões e versão do HTTP seguem diferentes. Faz com que o que sobra da diferença entre eles seja caminho de rede, e não escolha de estimador.
  • Não decide servidor, URL nem cabeçalho. Isso é de quem chama.

Honestidade da medição

Coisas que o motor faz porque um número bonito e errado é pior que nenhum número:

  • Bytes recusados voltam. Um POST que o servidor negou com 413 não mediu nada: os bytes que já tinham sido contados são devolvidos ao contador, senão a fase mediria o que o servidor jogou fora.
  • Recuo com Retry-After. Status ruim é resposta instantânea. Sem espera, quatro conexões fazem centenas de requisições em dez segundos, que é justamente o que faz um serviço recusar. Quando o servidor diz quanto esperar, manda ele.
  • 413 encolhe o POST. O servidor dizendo "esse corpo é grande demais" é informação: corta pela metade e segue medindo do tamanho que couber.
  • A fase reporta as falhas. falhas > 0 significa que a fase entregou bytes mas mediu machucada, e isso precisa aparecer pra quem lê. Cinco de seis conexões morrendo no segundo 2 produzem um número baixo que parece um link ruim, e não é: é uma medição ferida.
  • Cada requisição leva um identificador próprio no lugar do marcador RMFNONCE. Sem ele, as conexões pedem todas a mesma URL, e qualquer cache no caminho pode devolver a mesma resposta. Aí o teste mede o cache.
  • O aquecimento continua sendo cobrado. Ele sai da conta da vazão, mas os bytes dele entram no total gastado: quem paga o plano pagou por eles também.

De onde veio a ideia

O esquecimento exponencial vem do OpenSpeedTest (MIT), e vale dizer com todas as letras. Ele acumula bytes e tempo e, de tempos em tempos, multiplica os dois pelo mesmo fator (ca *= f; ka *= f). Isso é esquecimento exponencial disfarçado de reset: a razão ca/ka não muda no instante do corte, só a memória do passado encolhe. É uma ideia boa e pouco reconhecida.

O que é igual

  • A conta base: dois acumuladores, um de bytes e um de tempo, envelhecendo pelo mesmo fator, e a vazão sai da razão entre eles. Nunca se calcula a vazão de um pedaço isolado, nos dois.
  • Medição por duração, e não por tamanho de arquivo.
  • Várias conexões em paralelo por fase.
  • Bytes contados na camada de aplicação enquanto passam, inclusive no upload: eles entram no contador enquanto sobem, não de uma vez quando o POST fecha.
  • O mesmo bloco reaproveitado em todos os POSTs, pra não gastar CPU gerando bytes dentro da medição.
  • Um identificador único por requisição na URL, pra ninguém no caminho servir de cache (?n=Math.random() lá, o marcador RMFNONCE aqui).
  • A latência fecha no mínimo das amostras.
  • O ponteiro é alimentado pela leitura viva do estimador.

O que é diferente

OpenSpeedTest RMF-2
Esquecimento em degraus (*= 0,01 de tempos em tempos) contínuo, exp(-dt/tau) a cada marca
Horizonte cresce de 0 a 10 s entre um corte e o seguinte constante (tau)
Download × upload memórias diferentes (guarda 1% e 10%) o mesmo tau nos dois
Número final média da cauda (últimos 0,6 da fase, teto de 7 s) percentil (p80) da fase útil
Aquecimento absorvido pelo corte descartado explicitamente
Correção de overhead 1,04 (+4%) nenhuma
Fim da fase o ponteiro varre até zero e o resultado aparece depois o ponteiro converge pro resultado
Jitter descarta a metade pior das variações antes da média média de todas, na ordem em que chegaram
Latência sob carga não mede mede nas duas fases, em conexão própria
Quando dá errado um "Network Error" pra tudo 429, 413, Retry-After, recuo exponencial, marca de degradado, aborta se a rede trocar
Servidor automático, o de menor ping escolha de quem mede, catálogo vindo do servidor
Onde roda XHR no navegador nativo, Kotlin e Swift
Padrões 12 s, 6 e 6 conexões 10 s, 4 no download e 3 no upload

Em uma frase: pegamos a ideia do esquecimento e trocamos tudo o que vem depois dela, que é como o número é fechado e o que acontece quando a medição dá errado.

E os quatro lado a lado, que é o que interessa pra quem compara números entre aplicativos:

Ookla LibreSpeed OpenSpeedTest RMF-2
Como fecha o número média depois de podar as pontas (relatado como 30/10) média cumulativa da fase média das leituras da cauda percentil (p80) da série
Memória da leitura viva não publicado a fase inteira exponencial em degraus exponencial contínua (tau)
Descarte do começo não publicado grace time (1,5 s no down, 3 s no up) absorvido pelo corte warmup explícito, e piso em warmup + 2*tau
Correção de overhead não publicado 1,06 (+6%) 1,04 (+4%) nenhuma
Duração e conexões padrão não publicado 15 s, 6 down / 3 up 12 s, 6 e 6 10 s, 4 down / 3 up
Código aberto não sim (LGPL) sim (MIT) sim (MIT)

O que está como "não publicado" é isso mesmo: a Ookla não documenta o método, e o que circula sobre a poda 30/10 é entendimento público. Os outros três estão lidos direto do código.

Nenhuma dessas diferenças torna o outro errado. Elas respondem perguntas diferentes, e a nossa está escrita lá em cima.

Estrutura

kotlin/SpeedtestEngine.kt      # a referência, onde o algoritmo está explicado
swift/SpeedtestEngine.swift    # porte literal

Literal é requisito, não estilo: os dois números vão para a mesma coluna do mesmo banco, e duas medições só são comparáveis se a conta for a mesma. Mudou de um lado, muda do outro no mesmo commit.

Uso

val fase = Fase(Config(upload = false, urlTemplate = "https://exemplo/down?n=RMFNONCE")) { a ->
    println("${a.progress} ${a.mbps} Mbps")
}
val r = fase.correr()      // bloqueia até a fase fechar
println("${r.mbps} Mbps, ${r.bytes} bytes, ${r.falhas} falhas")
let fase = Fase(Config(upload: false, urlTemplate: "https://exemplo/down?n=RMFNONCE")) { a in
  print("\(a.progress) \(a.mbps) Mbps")
}
fase.correr { r in print("\(r.mbps) Mbps, \(r.bytes) bytes, \(r.falhas) falhas") }

Os padrões da Config são os valores de referência, os mesmos com que o método foi calibrado. No app eles não ficam no cliente: vêm do servidor, porque um parâmetro de método que só muda com versão nova na loja trava qualquer investigação.

Licença

MIT. Ver LICENSE.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages