← Storage: capacidade, desempenho e recuperação
11 / 12 · 60 MIN

Medir I/O e contratos de sincronização

Mede uma carga local limitada e interpreta operações lógicas, bytes, latência e fsync sem confundir a aplicação com o dispositivo.

Definir uma carga limitada e observável

O runner escreve ficheiros sintéticos de quatro MiB numa pasta temporária. Cada ficheiro recebe 256 operações lógicas de dezasseis KiB, com o mesmo payload conhecido. Há três perfis e duas repetições por perfil, totalizando 24 MiB lógicos. Uma operação pode precisar de mais de uma chamada de escrita se a interface aceitar apenas parte do buffer; o código continua com os bytes restantes e conta as chamadas. Não assume que uma chamada equivale a uma operação física do dispositivo. O objeto de ficheiro Python é aberto sem buffering próprio, mas as caches do sistema operativo continuam ativas. Não há direct I/O, expulsão de caches, dispositivos raw ou contadores do kernel. O objetivo é aprender a instrumentar e interpretar uma carga pequena, sem atribuir estes números a um volume de produção.

Manter o contrato de sincronização visível

O perfil end-only pede fsync no fim das 256 operações. O perfil every-32 pede-o oito vezes, depois de cada grupo completo. O perfil every-write faz 256 chamadas. Os três escrevem os mesmos bytes, mas executam trabalho diferente. A duração do lote começa depois de abrir o ficheiro e termina depois da última sincronização prevista; exclui fecho e leitura de verificação. Em end-only, o tempo do fsync final é guardado separadamente e não pertence às amostras individuais das escritas. Nos restantes perfis, a amostra da operação que desencadeia fsync inclui essa chamada. Compara estas definições antes de interpretar p95 ou MiB/s. Sucesso de fsync e leitura com hash correto são observações locais; não demonstram sobrevivência a perda de energia, comportamento de caches de hardware ou recuperação de diretórios após crash.

Calcular taxas e percentis com fronteiras explícitas

O relógio perf_counter_ns permite calcular intervalos monotónicos. Nanossegundos são a unidade devolvida, não uma promessa de precisão ou de conclusão instantânea. O relatório divide bytes e operações lógicas pelo tempo total do lote. A verificação pode confirmar que taxa de operações multiplicada por tamanho lógico corresponde à taxa de bytes, dentro desta mesma fronteira. Guarda também cada duração para calcular percentis com uma regra declarada: nearest rank escolhe a posição ceil(p × n) da sequência ordenada, contando a partir de um. Com 256 amostras, p99 usa a posição 254. Uma amostra tão curta tem pouca informação sobre eventos raros; não transforma p99 numa garantia. As durações incluem escalonamento e overhead do caminho observado e não isolam latência física do disco.

Repetir sem inventar uma classificação universal

As duas repetições são executadas numa ordem fixa e com caches ativas. Isso limita qualquer comparação causal entre perfis. O runner não exige que um perfil seja sempre mais rápido nem que os tempos se repitam. Verifica conteúdo completo, número de operações, número de fsync e coerência dos intervalos. Cada execução conserva os seus valores reais. Para estudar desempenho de uma aplicação, prepara uma carga representativa, duração suficiente, baseline comparável, ordem ou condições controladas e visibilidade de erros e concorrência. Um ensaio que termina com escrita incompleta é uma falha, mesmo se a taxa calculada parecer elevada. O código completo abaixo usa apenas a sua pasta temporária e remove os ficheiros no fim. Os modelos da aula seguinte são calculados separadamente e não provêm de telemetria deste host.

#!/usr/bin/env python3
"""Original bounded local I/O measurement lab: six 4 MiB files and four synthetic planning models."""
import argparse,hashlib,json,math,os,platform,statistics,tempfile,time
from fractions import Fraction
from pathlib import Path

def nearest(values,p):return sorted(values)[max(0,math.ceil(p*len(values))-1)]
def main(output):
    checks=[];trials=[]
    def check(label,ok):assert ok,label;checks.append(label)
    block=bytes(range(256))*64;count=256;expected=hashlib.sha256(block*count).hexdigest()
    clock=time.get_clock_info('perf_counter');check('measurement clock is monotonic',clock.monotonic)
    with tempfile.TemporaryDirectory(prefix='dr-storage-measurement-')as temporary:
        root=Path(temporary)
        for repeat in range(2):
            for name,interval in [('end-only',0),('every-32',32),('every-write',1)]:
                path=root/f'{repeat}-{name}.bin';write_ns=[];operation_ns=[];sync_ns=[];written=0;calls=0
                with path.open('wb',buffering=0)as f:
                    start=time.perf_counter_ns()
                    for i in range(count):
                        op_start=time.perf_counter_ns();remaining=memoryview(block)
                        while remaining:
                            n=f.write(remaining)
                            if n is None or n<=0:raise RuntimeError('No progress during bounded file write')
                            written+=n;calls+=1;remaining=remaining[n:]
                        write_ns.append(time.perf_counter_ns()-op_start)
                        if interval and (i+1)%interval==0:
                            t=time.perf_counter_ns();os.fsync(f.fileno());sync_ns.append(time.perf_counter_ns()-t)
                        operation_ns.append(time.perf_counter_ns()-op_start)
                    final_sync=0
                    if not interval or count%interval:
                        t=time.perf_counter_ns();os.fsync(f.fileno());final_sync=time.perf_counter_ns()-t;sync_ns.append(final_sync)
                    elapsed=time.perf_counter_ns()-start
                actual=hashlib.sha256(path.read_bytes()).hexdigest();label=f'{repeat}-{name}'
                check(label+': complete byte count',written==count*len(block)==path.stat().st_size)
                check(label+': complete content hash',actual==expected)
                check(label+': positive elapsed and sample count',elapsed>0 and len(write_ns)==len(operation_ns)==count)
                check(label+': expected fsync calls',len(sync_ns)==(1 if not interval else count//interval))
                check(label+': operation covers its write time',all(a>=b>=0 for a,b in zip(operation_ns,write_ns)))
                check(label+': measured subintervals fit batch',sum(operation_ns)+final_sync<=elapsed)
                trials.append(dict(repeat=repeat,profile=name,blockBytes=len(block),logicalOperations=count,writeSystemCalls=calls,bytes=written,fsyncCalls=len(sync_ns),fsyncEvery=interval,elapsedNs=elapsed,writeNs=write_ns,operationNs=operation_ns,fsyncNs=sync_ns,finalSyncOutsideOperationNs=final_sync,operationP50Ns=nearest(operation_ns,.5),operationP95Ns=nearest(operation_ns,.95),operationP99Ns=nearest(operation_ns,.99),operationMeanNs=statistics.mean(operation_ns),logicalOpsPerSecond=count/(elapsed/1e9),MiBPerSecond=(written/1024/1024)/(elapsed/1e9),contentHash=actual,scope='Sequential local buffered OS I/O; Python file object unbuffered. One synchronous logical operation at a time. Batch timer includes fsync requests but excludes open, close and hash readback. Not device IOPS or power-loss durability.'))
        check('all trials use the same complete payload',len({t['contentHash']for t in trials})==1)
    check('temporary trial files removed',not root.exists())
    # These models are intentionally synthetic, not measured host/device counters.
    a=[1]*1000;b=[100]*10;combined=a+b
    percentiles=dict(unit='ms',samplesA=len(a),samplesB=len(b),p95A=nearest(a,.95),p95B=nearest(b,.95),meanOfP95=(nearest(a,.95)+nearest(b,.95))/2,combinedP95=nearest(combined,.95),combinedMean=str(Fraction(sum(combined),len(combined))),method='Nearest rank: sorted values at ceil(p*n), one-based. Original synthetic samples.')
    check('percentiles do not average into combined percentile',percentiles['meanOfP95']==50.5 and percentiles['combinedP95']==1 and percentiles['combinedMean']=='200/101')
    size=64;volume_iops=3000;volume_mib=200;host_mib=120;other_mib=40;available=host_mib-other_mib;limit=min(volume_iops,Fraction(volume_mib*1024,size),Fraction(available*1024,size))
    limits=dict(sizeKiB=size,volumeIops=volume_iops,volumeMiBPerSecond=volume_mib,hostMiBPerSecond=host_mib,otherMiBPerSecond=other_mib,remainingHostMiBPerSecond=available,theoreticalIops=str(limit),bindingBoundary='remaining host throughput',measured=False)
    check('host aggregate can bind before volume limits',limit==1280)
    free=180;burst=40;reserve=20;growth=30;lead=4;days=Fraction(free-burst-reserve,growth)
    capacity=dict(freeGiB=free,burstGiB=burst,reserveGiB=reserve,constantDailyGrowthGiB=growth,leadDays=lead,daysUntilReserve=str(days),remainingAtCompletionGiB=free-burst-growth*lead,extraTimeMarginDays=str(days-lead),measured=False)
    check('capacity plan has zero extra lead-time margin',days==4 and capacity['remainingAtCompletionGiB']==20 and capacity['extraTimeMarginDays']=='0')
    queue=dict(meanOutstanding=8,completedPerSecond=2000,meanResidenceSeconds=str(Fraction(8,2000)),assumptions='Stable comparable window, same population and boundary; uses means, not a percentile. Synthetic model, no host queue measured.')
    check('stable queue model gives four milliseconds',queue['meanResidenceSeconds']=='1/250')
    report=dict(python=platform.python_version(),system=platform.system(),release=platform.release(),machine=platform.machine(),runnerSha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),clock=dict(implementation=clock.implementation,monotonic=clock.monotonic,adjustable=clock.adjustable,resolution=clock.resolution),checks=checks,trials=trials,models=dict(percentiles=percentiles,limits=limits,capacity=capacity,queue=queue),totalLogicalBytes=sum(t['bytes']for t in trials),scope='Actual bounded sequential file timing on the recorded host; six 4 MiB trials, 24 MiB logical writes, plus four synthetic planning models. Timing varies with caches, scheduler, filesystem and host load. No direct I/O, cache eviction, raw device access, kernel I/O counters, Linux execution, network storage, power failure, production benchmark or universal performance ranking.')
    if output:Path(output).write_text(json.dumps(report,indent=2)+'\n')
    print(json.dumps(dict(trials=len(trials),checks=len(checks),models=len(report['models']),totalLogicalBytes=report['totalLogicalBytes'],output=output)))
if __name__=='__main__':
    p=argparse.ArgumentParser();p.add_argument('--output');a=p.parse_args();main(a.output)
NA PRÁTICA

Tejo compara dois relatórios com o mesmo payload, mas um sincroniza cada escrita e o outro apenas o lote. A equipa alinha o contrato antes de declarar regressão.

Armadilhas comuns

buffering=0 como ausência de todas as caches; taxa lógica como IOPS físicos; melhor execução como capacidade garantida; fsync bem-sucedido como ensaio de falha de energia.

Tópicos relacionados: Desempenho e medição · Durabilidade e confirmação

Leva esta ideia contigo

Compara trabalho com o mesmo contrato e conserva o âmbito da medição. Uma taxa só é interpretável quando os bytes, as operações e o intervalo estão definidos.

Criar conta

Referência: Python operating-system synchronization interface · DR Storage 2026-09; selected Linux and AWS storage behavior