Skip to content

Repository files navigation

Simulador de GPU en Python

Simulacion educativa de un GPU con su jerarquia de componentes: Threads, Warps, Cores, SMs y memoria. Incluye un ISA (Instruction Set Architecture) propio y un assembler para escribir kernels.

Arquitectura simulada

GPU
└── SMs (Streaming Multiprocessors)
├── Warp Scheduler
├── Cores (ejecutan instrucciones)
├── Shared Memory (por bloque)
└── Warps
└── 32 Threads (cada uno con sus propios registros)
Memoria
├── Global Memory (compartida por todo el GPU, lenta)
└── Shared Memory (por bloque, rapida)

SIMT — Single Instruction, Multiple Threads: un warp ejecuta la misma instruccion en sus 32 threads simultaneamente, pero cada thread opera sobre sus propios datos.


Estructura del proyecto

modelo-gpu/
├── main.py
├── gpu/
│ ├── thread.py
│ ├── warp.py
│ ├── core.py
│ ├── scheduler.py
│ ├── memory.py
│ ├── sm.py
│ └── gpu.py
├── isa/
│ ├── opcodes.py
│ ├── instruction.py
│ └── assembler.py
├── kernels/
│ └── vector_add.asm
└── sim/
└── runner.py

Tareas en orden

Fase 1 — ISA (el lenguaje del GPU)

Tarea 1 — isa/opcodes.py Definir el conjunto de instrucciones del GPU.

  • Crear un enum Opcode con las instrucciones soportadas:
    • Aritmetica: ADD, SUB, MUL, DIV, ADDI (con inmediato)
    • Memoria: LOAD, STORE
    • Control: JMP, BEQ, BNE
    • Especiales: MOV, NOP, RET, GETID
  • GETID es la instruccion clave: le dice a cada thread cual es su indice

Como probar:

fromisa.opcodesimportOpcodeprint(Opcode.ADD) # Opcode.ADDprint(Opcode.ADD.value) # el valor asignadoprint(list(Opcode)) # lista todos los opcodes# Verificar que existen los 13 opcodes esperadosasserthasattr(Opcode, 'GETID')
asserthasattr(Opcode, 'BEQ')

Tarea 2 — isa/instruction.py Representar una instruccion como objeto.

  • Clase Instruction con campos: opcode, dest, src1, src2, label
  • Metodo __repr__ para debug: "ADD R1 R2 R3"
  • Soportar operandos inmediatos (numeros literales) ademas de registros

Como probar:

fromisa.opcodesimportOpcodefromisa.instructionimportInstructioni=Instruction(Opcode.ADD, dest='R1', src1='R2', src2='R3')
print(i) # ADD R1 R2 R3# Con inmediatoi2=Instruction(Opcode.ADDI, dest='R1', src1='R2', src2=5)
print(i2) # ADDI R1 R2 5# Con label (para saltos)i3=Instruction(Opcode.JMP, label='loop')
print(i3) # JMP loop

Fase 2 — Componentes de hardware

Tarea 3 — gpu/thread.py La unidad minima de ejecucion.

  • Clase Thread con: thread_id, registers (dict), pc, state, active
  • Estado posible: READY | RUNNING | FINISHED | STALLED
  • Metodos read_register(name) y write_register(name, value)

Como probar:

fromgpu.threadimportThreadt=Thread(thread_id=7)
print(t.thread_id) # 7print(t.pc) # 0print(t.state) # READYprint(t.active) # Truet.write_register('R1', 42)
print(t.read_register('R1')) # 42# Leer registro que no existe debe retornar 0 (o lanzar error claro)print(t.read_register('R99')) # 0

Tarea 4 — gpu/warp.py Grupo de 32 threads que ejecutan en lockstep.

  • Clase Warp con: warp_id, threads (lista de 32), active_mask, pc, state
  • Metodo get_active_threads() — retorna solo los threads activos
  • Metodo is_done() — True si todos los threads terminaron
  • Manejar warp divergence: cuando un branch divide los threads, ejecutar ambos caminos con mascaras distintas

Como probar:

fromgpu.warpimportWarpw=Warp(warp_id=0, start_thread_id=0)
print(len(w.threads)) # 32print(w.is_done()) # Falseprint(len(w.get_active_threads())) # 32# Simular que la mitad de los threads terminanfortinw.threads[:16]:
t.active=Falseprint(len(w.get_active_threads())) # 16print(w.is_done()) # False# Terminar todosfortinw.threads:
t.active=Falseprint(w.is_done()) # True

Tarea 5 — gpu/memory.py Subsistema de memoria.

  • Clase GlobalMemory: dict o lista como storage, metodos read(addr) y write(addr, value), simular latencia con ciclos de espera
  • Clase SharedMemory: una instancia por bloque, sin latencia, tamanio maximo configurable

Como probar:

fromgpu.memoryimportGlobalMemory, SharedMemorygm=GlobalMemory()
gm.write(0, 99)
print(gm.read(0)) # 99print(gm.latency) # ciclos de espera (ej: 100)# Shared memory — rapida, tamanio limitadosm=SharedMemory(size=1024)
sm.write(0, 42)
print(sm.read(0)) # 42# Verificar que SharedMemory no tiene latencia# y que GlobalMemory si la tiene

Tarea 6 — gpu/core.py El motor de ejecucion: aplica una instruccion a un thread.

  • Clase Core con core_id y busy
  • Metodo execute(instruction, thread, memory) que implementa cada opcode:
    • ADD/SUB/MUL/DIV: operaciones aritmeticas entre registros
    • ADDI: aritmetica con inmediato
    • LOAD/STORE: leer y escribir en memoria
    • GETID: escribir thread.thread_id en el registro destino
    • MOV/NOP/RET: utilidades
    • JMP/BEQ/BNE: modificar thread.pc

Como probar:

fromgpu.coreimportCorefromgpu.threadimportThreadfromgpu.memoryimportGlobalMemoryfromisa.opcodesimportOpcodefromisa.instructionimportInstructioncore=Core(core_id=0)
t=Thread(thread_id=5)
mem=GlobalMemory()
t.write_register('R1', 10)
t.write_register('R2', 3)
# ADDcore.execute(Instruction(Opcode.ADD, 'R3', 'R1', 'R2'), t, mem)
print(t.read_register('R3')) # 13# GETIDcore.execute(Instruction(Opcode.GETID, 'R0'), t, mem)
print(t.read_register('R0')) # 5# LOAD / STOREmem.write(0, 777)
core.execute(Instruction(Opcode.LOAD, 'R4', src1=0), t, mem)
print(t.read_register('R4')) # 777

Tarea 7 — gpu/scheduler.py Decide que warp ejecuta en cada ciclo.

  • Clase WarpScheduler con lista de warps y politica (round_robin o greedy)
  • Metodo next_warp() — retorna el proximo warp READY, saltando WAITING y DONE
  • round_robin: rotar en orden circular
  • greedy: siempre el primero disponible
  • Cuando un warp hace LOAD, marcarlo WAITING por N ciclos (simula latencia de memoria y latency hiding)

Como probar:

fromgpu.warpimportWarpfromgpu.schedulerimportWarpSchedulerwarps= [Warp(warp_id=i, start_thread_id=i*32) foriinrange(4)]
sched=WarpScheduler(warps, policy='round_robin')
# Round robin: debe rotar entre warpsprint(sched.next_warp().warp_id) # 0print(sched.next_warp().warp_id) # 1print(sched.next_warp().warp_id) # 2# Marcar warp 3 como WAITING, debe saltarlowarps[3].state='WAITING'sched2=WarpScheduler(warps, policy='round_robin')
ids= [sched2.next_warp().warp_idfor_inrange(6)]
print(ids) # nunca debe aparecer 3

Tarea 8 — gpu/sm.py El SM integra cores, warps, scheduler y memoria compartida.

  • Clase SM con: sm_id, cores, warps, scheduler, shared_memory, referencia a global_memory
  • Metodo assign_block(block) — crea los warps a partir de un bloque de threads
  • Metodo step() — ejecuta un ciclo de simulacion:
    1. Pedir al scheduler el siguiente warp
    2. Fetch de la instruccion en warp.pc
    3. Ejecutar en todos los threads activos del warp (un core por thread)
    4. Avanzar warp.pc
    5. Retornar True si quedan warps sin terminar

Como probar:

fromgpu.smimportSMfromgpu.memoryimportGlobalMemoryfromisa.opcodesimportOpcodefromisa.instructionimportInstructionprogram= [
Instruction(Opcode.GETID, 'R0'),
Instruction(Opcode.RET),
]
gm=GlobalMemory()
sm=SM(sm_id=0, num_cores=32, global_memory=gm)
block= {'block_id': 0, 'threads': list(range(32))}
sm.assign_block(block, program)
# Ejecutar ciclos hasta terminarwhilesm.step():
pass# Verificar que cada thread tiene su ID en R0forwarpinsm.warps:
fortinwarp.threads:
print(t.thread_id, t.read_register('R0')) # deben coincidir

Tarea 9 — gpu/gpu.py El GPU completo: coordina los SMs y lanza kernels.

  • Clase GPU con: num_sms, sms, global_memory, config
  • Metodo launch_kernel(program, grid_dim, block_dim):
    1. Calcular total de bloques
    2. Distribuir bloques entre SMs (round-robin)
    3. Cada SM recibe sus bloques via assign_block()
  • Metodo run() — loop hasta que todos los SMs terminen, contar ciclos

Como probar:

fromgpu.gpuimportGPUfromisa.opcodesimportOpcodefromisa.instructionimportInstructionprogram= [
Instruction(Opcode.GETID, 'R0'),
Instruction(Opcode.RET),
]
gpu=GPU(num_sms=2, cores_per_sm=32)
gpu.launch_kernel(program, grid_dim=4, block_dim=32)
stats=gpu.run()
print(stats['cycles']) # numero de ciclos totalesprint(stats['instructions']) # instrucciones ejecutadas# Con 4 bloques en 2 SMs, cada SM debe haber procesado 2 bloques

Fase 3 — Assembler y kernels

Tarea 10 — isa/assembler.py Convierte texto plano a lista de instrucciones.

  • Funcion assemble(source: str) -> list[Instruction]
  • Parsear linea por linea: ignorar comentarios (#) y lineas vacias
  • Detectar labels (terminan en :) y resolver saltos a indices numericos
  • Retornar lista de objetos Instruction lista para ejecutar

Como probar:

fromisa.assemblerimportassemblesource="""# Suma simple GETID R0 ADDI R1 R0 10loop: NOP BNE R0 R1 loop RET"""program=assemble(source)
print(len(program)) # 5 instrucciones (sin comentarios ni vacias)print(program[0]) # GETID R0print(program[3]) # BNE R0 R1 2 <- label resuelta a indice

Tarea 11 — kernels/vector_add.asm Primer kernel real escrito en el ISA propio.

  • Suma de dos vectores A y B, resultado en C
  • Cada thread calcula C[i] = A[i] + B[i] usando GETID para obtener su indice
  • Asumir layout en memoria: A en [0..N), B en [N..2N), C en [2N..3N)

Como probar:

fromisa.assemblerimportassemblewithopen("kernels/vector_add.asm") asf:
program=assemble(f.read())
# Verificar que ensambla sin erroresprint(len(program), "instrucciones")
# Inspeccion manual: la primera instruccion debe ser GETIDprint(program[0]) # GETID Rx# La ultima debe ser RETprint(program[-1]) # RET

La prueba real es correrlo con el GPU completo en la Tarea 13.


Fase 4 — Integracion

Tarea 12 — sim/runner.py Orquesta la ejecucion completa.

  • Clase SimRunner con metodos:
    • load_data(array_a, array_b, n) — escribe inputs en global memory
    • run_kernel(program, grid_dim, block_dim) — ensambla, lanza, corre y lee resultados
    • print_stats(stats) — imprime ciclos, instrucciones, throughput

Como probar:

fromgpu.gpuimportGPUfromsim.runnerimportSimRunnergpu=GPU(num_sms=2, cores_per_sm=32)
runner=SimRunner(gpu)
N=32A=list(range(N))
B=list(range(N, 2*N))
runner.load_data(A, B, N)
# Verificar que la memoria global tiene los datos correctosprint(gpu.global_memory.read(0)) # 0 (A[0])print(gpu.global_memory.read(N)) # 32 (B[0])

Tarea 13 — main.py Demo y validacion end-to-end.

  • Configurar el GPU
  • Crear arrays de prueba, cargarlos, ejecutar vector_add
  • Verificar que C[i] == A[i] + B[i] para todo i
  • Imprimir estadisticas

Como probar:

python main.py

Salida esperada:

Ciclos totales: <N>
Instrucciones ejecutadas: <N>
Resultado correcto: True

Si Resultado correcto es False, revisar la cadena hacia atras: primero core.py (ejecuta bien ADD/LOAD/STORE?), luego assembler.py (resuelve bien las direcciones?), luego vector_add.asm (el layout de memoria es correcto?).


Conceptos clave

ConceptoDescripcion
SIMT32 threads ejecutan la misma instruccion, datos distintos
Warp divergenceUn branch divide el warp — se ejecutan ambos caminos con mascara
Latency hidingMientras un warp espera memoria, el scheduler corre otro
OccupancyCuantos warps activos caben en un SM (limitado por registros y shared memory)
Active maskBitmask que indica que threads del warp estan activos en este ciclo

Ejemplo de uso (objetivo final)

gpu=GPU(num_sms=4, cores_per_sm=32)
runner=SimRunner(gpu)
N=128runner.load_data(A=list(range(N)), B=list(range(N, 2*N)), n=N)
withopen("kernels/vector_add.asm") asf:
stats=runner.run_kernel(f.read(), grid_dim=4, block_dim=32)
runner.print_stats(stats)
# Ciclos totales: 42# Instrucciones ejecutadas: 1024# Resultado correcto: True

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages