Esse é o motor de medição utilizado pelo Redes Móveis Fixas em suas versões Kotlin (Android) e Swift (iOS).
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, comoverheadCompensationFactorde 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%.
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.
- 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.
- 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.
- 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.
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:
lambdasai dodtreal 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.
caekasã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 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.
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.
- 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.
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 > 0significa 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.
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.
- 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 marcadorRMFNONCEaqui). - A latência fecha no mínimo das amostras.
- O ponteiro é alimentado pela leitura viva do estimador.
| 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.
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.
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.
MIT. Ver LICENSE.