Extensiones y utilidades de alto rendimiento para Python.
Project description
fastcorex
Extensión en C para acelerar loops y estructuras de datos comunes en Python. No es un reemplazo de NumPy ni Pandas — es una capa delgada que elimina las partes tediosas y repetitivas (agrupar, deduplicar, aplanar, contar, filtrar, particionar, fusionar dicts, generar slugs, acceso anidado seguro, rellenar texto, ventanas deslizantes) que normalmente se reescriben a mano en cada proyecto.
Instalación
pip install fastcorex
Requiere Python 3.9 o superior y un compilador de C (se compila al instalar, como cualquier extensión nativa).
Uso rápido
import fastcorex as fx
fx.fast_sum([1, 2, 3]) # 6.0
fx.count_freq(["a", "b", "a"]) # {'a': 2, 'b': 1}
fx.unique([3, 1, 2, 1, 3]) # [3, 1, 2]
fx.filter_gt([1, 5, 10, 3], 4.0) # [5.0, 10.0]
fx.groupby(lista_de_dicts, "categoria") # dict agrupado
fx.flatten([1, [2, [3, 4]], 5]) # [1, 2, 3, 4, 5]
fx.unique_by(lista_de_dicts, "id") # FastList dedup por campo (encadenable)
fx.chunk([1, 2, 3, 4, 5], 2) # [[1, 2], [3, 4], [5]]
fx.safe_get(d, "a.b.c", default=None) # acceso anidado sin try/except
fx.clamp(15.0, 0.0, 10.0) # 10.0
fx.pick({"a": 1, "b": 2, "c": 3}, ["a"]) # {'a': 1}
fx.omit({"a": 1, "b": 2, "c": 3}, ["a"]) # {'b': 2, 'c': 3}
fx.deep_merge(config_base, config_local) # dict fusionado recursivamente
fx.slugify("Título con Ñandú") # "titulo-con-nandu"
fx.partition(numeros, lambda n: n > 0) # (positivos, no_positivos)
fx.dedupe_consecutive([1, 1, 2, 2, 1]) # [1, 2, 1]
fx.flatten_dict({"a": {"b": 1}}) # {'a.b': 1}
fx.invert_dict({"a": 1, "b": 2}) # {1: 'a', 2: 'b'}
fx.pad("hi", 6, mode="center") # " hi "
fx.clip_outliers([1, -50, 100], 0, 10) # [1.0, 0.0, 10.0]
fx.rolling_window([1, 2, 3, 4], 2) # [[1, 2], [2, 3], [3, 4]]
fx.filter_range(numeros, 5, 15) # sin callback, más rápido que filter()
fx.partition_gt(numeros, 10) # sin callback, más rápido que partition()
fx.ensure_list(5) # [5]
fx.first_or_default(lista, predicado) # primer match o default
Documentación de funciones
Funciones de la versión 0.1.x
fast_sum(lista) — Suma todos los elementos numéricos de una lista.
fx.fast_sum([1, 2, 3, 4.5]) # 10.5
count_freq(lista) — Cuenta cuántas veces aparece cada elemento. Reemplaza un loop de 4 líneas con dict.get() por una sola llamada.
fx.count_freq(["a", "b", "a", "c", "b", "a"]) # {'a': 3, 'b': 2, 'c': 1}
unique(lista) — Elimina duplicados manteniendo el orden original. Reemplaza el patrón de set() + loop + append.
fx.unique([3, 1, 2, 1, 3, 4]) # [3, 1, 2, 4]
filter_gt(lista, umbral) — Devuelve solo los elementos mayores al umbral dado.
fx.filter_gt([1, 5, 10, 3, 8], 4.0) # [5.0, 10.0, 8.0]
groupby(lista_de_dicts, clave) — Agrupa una lista de diccionarios según el valor de una clave. Devuelve un dict normal. Reemplaza el patrón de dict + setdefault manual.
fx.groupby(ventas, "categoria") # {'ropa': [...], 'comida': [...]}
flatten(lista_anidada) — Aplana listas anidadas de cualquier profundidad. Reemplaza una función recursiva escrita a mano.
fx.flatten([1, [2, 3, [4, [5, 6]], 7], 8]) # [1, 2, 3, 4, 5, 6, 7, 8]
unique_by(lista_de_dicts, clave) — Deduplica diccionarios según el valor de un campo específico, no el objeto completo. Devuelve un FastList (ver sección de encadenamiento).
fx.unique_by(ventas, "id") # FastList sin ids repetidos
chunk(lista, tamaño) — Parte una lista en sublistas de tamaño fijo, sin solaparse; el último chunk puede quedar más corto. Reemplaza el slicing manual con range(0, len(lista), tamaño).
fx.chunk([1, 2, 3, 4, 5, 6, 7], 3) # [[1, 2, 3], [4, 5, 6], [7]]
safe_get(dict, "a.b.c", default=None) — Acceso anidado seguro a diccionarios usando un path con puntos. Reemplaza el try/except (KeyError, TypeError) que normalmente envuelve un acceso encadenado. default puede pasarse posicional o como keyword.
fx.safe_get({"a": {"b": {"c": 42}}}, "a.b.c") # 42
fx.safe_get({"a": {"b": {}}}, "a.b.c", default="N/A") # "N/A"
Un path vacío, o con un segmento vacío ("a..b", ".a", "a."), lanza ValueError en vez de devolver silenciosamente el default — un path malformado suele ser un bug en quien llama, no un caso de "no encontrado".
clamp(valor, minimo, maximo) — Acota un número al rango [minimo, maximo]. Reemplaza max(minimo, min(valor, maximo)) o un if/elif/else.
fx.clamp(15.0, 0.0, 10.0) # 10.0
fx.clamp(-5.0, 0.0, 10.0) # 0.0
Funciones de la versión 0.2.0
pick(dict, claves) — Devuelve un nuevo dict con solo las claves indicadas que existan en el original; las ausentes se ignoran sin error. Reemplaza {k: d[k] for k in claves if k in d}.
fx.pick({"nombre": "Ana", "edad": 30, "email": "a@x.com"}, ["nombre", "email"])
# {'nombre': 'Ana', 'email': 'a@x.com'}
omit(dict, claves) — Lo inverso de pick: devuelve un nuevo dict sin las claves indicadas. Reemplaza {k: v for k, v in d.items() if k not in claves}.
fx.omit({"nombre": "Ana", "password": "secreta", "edad": 30}, ["password"])
# {'nombre': 'Ana', 'edad': 30}
deep_merge(base, override) — Fusiona override sobre base recursivamente: cuando ambos tienen un dict en la misma clave, se fusionan sus contenidos en vez de que uno reemplace al otro; en cualquier otro caso, override gana. Ninguno de los dos argumentos originales se modifica.
config_base = {"db": {"host": "localhost", "port": 5432}, "debug": False}
config_local = {"db": {"port": 5433}}
fx.deep_merge(config_base, config_local)
# {'db': {'host': 'localhost', 'port': 5433}, 'debug': False}
slugify(texto) — Normaliza un string a minúsculas, sin acentos (cubre á é í ó ú ü ñ ç y sus mayúsculas), con guiones en vez de espacios o símbolos, sin guiones duplicados ni al inicio/final.
fx.slugify("Título de Sección: ¡Importante!") # "titulo-de-seccion-importante"
partition(lista, predicado) — Recorre la lista una sola vez y la separa en (cumplen, no_cumplen) según predicado(item), en vez de hacer dos pasadas. Devuelve una tupla de dos FastList. Nota de rendimiento: ver la sección de benchmarks — para el caso de "separar por un umbral numérico", partition_gt es 4x-6x más rápida.
pares, impares = fx.partition(range(10), lambda n: n % 2 == 0)
# ([0, 2, 4, 6, 8], [1, 3, 5, 7, 9])
dedupe_consecutive(lista) — Colapsa elementos repetidos que aparecen uno justo después del otro. A diferencia de unique(), no deduplica globalmente: dedupe_consecutive([1, 2, 1]) deja [1, 2, 1] intacto, porque el segundo 1 no es consecutivo con el primero.
fx.dedupe_consecutive([1, 1, 2, 2, 2, 1, 3, 3]) # [1, 2, 1, 3]
Funciones nuevas (0.3.0)
flatten_dict(dict, sep=".") — Aplana un dict anidado a un solo nivel, generando claves tipo "a.b.c" para cada valor no-dict encontrado en profundidad. Es el inverso conceptual de safe_get: en vez de bajar por un path con puntos, genera todos los paths posibles de una vez. Los valores que son listas no se aplanan, se conservan tal cual.
fx.flatten_dict({"usuario": {"nombre": "Ana", "direccion": {"ciudad": "Lima"}}})
# {'usuario.nombre': 'Ana', 'usuario.direccion.ciudad': 'Lima'}
fx.flatten_dict({"a": {"b": 1}}, sep="/") # {'a/b': 1}
Útil para "achatar" una respuesta de API anidada antes de escribirla a una fila de CSV o de base de datos plana.
invert_dict(dict) — Devuelve un nuevo dict con claves y valores intercambiados. Si hay valores duplicados, el último gana (mismo comportamiento que reconstruirlo a mano con un loop). Los valores deben ser hasheables, igual que exige Python al usarlos como clave.
fx.invert_dict({"rojo": "#FF0000", "verde": "#00FF00"})
# {'#FF0000': 'rojo', '#00FF00': 'verde'}
Reemplaza: {v: k for k, v in d.items()}.
pad(texto, ancho, fill=" ", mode="right") — Rellena un string a un ancho mínimo. mode puede ser "right" (rellena a la derecha, texto alineado a la izquierda), "left" (rellena a la izquierda, texto alineado a la derecha) o "center". Si el texto ya mide ancho o más, se devuelve sin cambios.
fx.pad("hi", 6) # "hi "
fx.pad("hi", 6, mode="left") # " hi"
fx.pad("hi", 6, mode="center") # " hi "
fx.pad("hi", 6, fill="*") # "hi****"
Es equivalente a str.ljust/str.rjust/str.center, pero unificados bajo un solo nombre de parámetro (mode) y con validación explícita del carácter de relleno (debe ser exactamente uno) y del valor de mode, en vez de tener que recordar cuál de los tres métodos usar y qué pasa si fill mide más de un carácter (con los métodos nativos, silenciosamente solo se usa mal). Ver la sección de benchmarks para las dos comparaciones de rendimiento distintas que aplican aquí.
clip_outliers(lista, minimo, maximo) — Devuelve una nueva lista con cada número acotado al rango [minimo, maximo]. Es clamp() aplicado a una lista completa de una vez, en vez de un loop + clamp() por elemento.
fx.clip_outliers([1.0, -50.0, 100.0, 5.0], 0.0, 10.0) # [1.0, 0.0, 10.0, 5.0]
Útil para descartar valores atípicos de sensores, precios o mediciones antes de graficarlos o promediarlos.
rolling_window(lista, tamaño) — Genera una lista de sublistas, cada una una "ventana" de tamaño elementos consecutivos que se desliza de a uno. A diferencia de chunk() (que particiona sin solapamiento), rolling_window sí solapa: con tamaño 2, [1,2,3,4] da [[1,2],[2,3],[3,4]], no [[1,2],[3,4]]. Si tamaño es mayor que la longitud de la lista, devuelve una lista vacía.
fx.rolling_window([1, 2, 3, 4, 5], 3) # [[1, 2, 3], [2, 3, 4], [3, 4, 5]]
Pensado para promedios móviles, detección de tendencias, o comparar cada elemento contra su vecindario inmediato — ver el ejemplo de encadenamiento con .map() más abajo para un promedio móvil real.
Especializadas de rendimiento (0.3.0)
Estas dos funciones resuelven el mismo problema que partition()/.filter() con una lambda de comparación numérica, pero sin invocar ningún callback de Python — ver la sección de benchmarks para el porqué y la magnitud real de la mejora (4x-7x según el caso).
filter_range(lista, minimo, maximo, inclusive=True) — Devuelve los elementos dentro de [minimo, maximo] (con inclusive=True, el valor por defecto) o (minimo, maximo) exclusivo en ambos extremos (inclusive=False). Generaliza filter_gt a un rango completo.
fx.filter_range([1, 5, 10, 15, 20], 5, 15) # [5, 10, 15]
fx.filter_range([1, 5, 10, 15, 20], 5, 15, inclusive=False) # [10]
partition_gt(lista, umbral) — Especialización de partition() para separar por un único umbral numérico: devuelve (mayores, resto), igual que partition(lista, lambda x: x > umbral) pero sin el costo de invocar Python en cada elemento.
fx.partition_gt([1, 5, 10, 15, 20], 10) # ([15, 20], [1, 5, 10])
Utilidades en Python puro (0.2.0)
Estas dos funciones se implementaron directamente en Python, no en C, porque su costo ya es mínimo en el intérprete y una extensión de C no traería ninguna ganancia medible.
ensure_list(valor) — Envuelve valor en una lista si no es ya una lista o tupla.
fx.ensure_list(5) # [5]
fx.ensure_list([1, 2, 3]) # [1, 2, 3]
first_or_default(iterable, predicado=None, default=None) — Devuelve el primer elemento que cumple predicado, o default si ninguno cumple, sin lanzar StopIteration. Funciona con cualquier iterable, incluidos generadores.
fx.first_or_default([1, 2, 3, 4], lambda x: x > 2) # 3
fx.first_or_default([], default="vacío") # "vacío"
FastList: encadenamiento de métodos
unique_by(), partition(), partition_gt() y algunas otras funciones devuelven un FastList: un subtipo de list que se comporta como una lista normal (indexable, iterable, con len(), slicing, etc.) pero además expone métodos propios para seguir encadenando sin volver a pasar por funciones sueltas del módulo.
Métodos disponibles en FastList:
.groupby(clave)— igual quefx.groupby(), pero devuelve otroFastList(de pares[clave, sublista]) en vez de un dict, para poder seguir encadenando..sum(campo=None)— sin argumento, suma los elementos como números. Con argumento, asume que elFastListviene de un.groupby()y devuelve un dict{clave: suma_del_campo}..count()— asume que elFastListviene de un.groupby()y devuelve un dict{clave: cantidad}..filter(predicado)— devuelve unFastListcon los elementos dondepredicado(item)es verdadero..map(función)— devuelve unFastListconfunción(item)aplicada a cada elemento..unique_by(clave)— igual quefx.unique_by(), encadenable..flatten()— igual quefx.flatten(), encadenable..chunk(tamaño)— igual quefx.chunk(), encadenable. Nota: elFastListcontenedor es encadenable, pero cada sublista interna es una lista normal, no unFastList..partition(predicado)— igual quefx.partition(), devuelve una tupla de dosFastList..dedupe_consecutive()— igual quefx.dedupe_consecutive(), encadenable..clip(minimo, maximo)(0.3.0) — igual quefx.clip_outliers(), encadenable..rolling(tamaño)(0.3.0) — igual quefx.rolling_window(), encadenable..filter_range(minimo, maximo, inclusive=True)(0.3.0) — igual quefx.filter_range(), sin callback, encadenable..partition_gt(umbral)(0.3.0) — igual quefx.partition_gt(), sin callback, devuelve una tupla de dosFastList.
ventas_unicas = fx.unique_by(ventas, "id")
totales = ventas_unicas.groupby("categoria").sum("monto")
# {'ropa': 240.0, 'comida': 30.0, 'tech': 500.0}
Encadenamientos que combinan métodos de distintas versiones en una sola expresión:
# Promedio móvil real: ventana deslizante + promedio de cada ventana
precios = fx.FastList([10.0, 12.0, 11.0, 15.0, 14.0, 20.0])
promedios_moviles = precios.rolling(3).map(lambda ventana: sum(ventana) / len(ventana))
# [11.0, 12.67, 13.33, 16.33]
# Filtrar por rango sin callback, acotar outliers, y transformar — sin
# invocar Python en los dos primeros pasos
resultado = (
fx.FastList(mediciones)
.filter_range(5, 25)
.clip(10, 20)
.map(lambda n: n * 2)
)
# Separar por umbral sin callback, y seguir operando sobre cada mitad
mayores, resto = fx.FastList(valores).partition_gt(100)
top = mayores.map(lambda n: n - 100)
ventanas_del_resto = resto.rolling(5)
# Limpieza de datos: colapsar repetidos, acotar rango, filtrar
resultado = (
fx.FastList(lecturas_sensor)
.dedupe_consecutive()
.clip(0, 100)
.filter_range(0, 50)
)
Nota: .sum(campo) y .count() esperan específicamente el formato que produce .groupby() (una lista de pares [clave, sublista]); si se les pasa otra cosa, lanzan TypeError con un mensaje explicando qué esperaban.
Ejemplo real combinando varias funciones
Sin fastcorex (19 líneas): un loop para deduplicar por id, otro para agrupar por categoría, y un tercer loop anidado para sumar montos por grupo.
Con fastcorex, dos formas equivalentes:
# Con funciones sueltas (4 líneas)
ventas_unicas = fx.unique_by(ventas, "id")
grupos = fx.groupby(ventas_unicas, "categoria")
totales = {cat: fx.fast_sum([i["monto"] for i in items]) for cat, items in grupos.items()}
# Con encadenamiento de FastList (2 líneas)
ventas_unicas = fx.unique_by(ventas, "id")
totales = ventas_unicas.groupby("categoria").sum("monto")
Ambas versiones dan el mismo resultado: {'ropa': 240.0, 'comida': 30.0, 'tech': 500.0}
Resultados de benchmark
Medido con listas de 200,000 y 2,000,000 de elementos, mejor tiempo de 5 corridas (ver benchmark.py en el repositorio para reproducirlo).
Con 200,000 elementos: fast_sum 2.5x, count_freq 1.6x, unique 2.0x, filter_gt 2.0x, groupby 1.9x, flatten 11.7x, unique_by 1.2x, chunk 1.4x, safe_get 1.1x, clamp 2.0x, pick 1.8x, omit 5.6x, deep_merge 14.0x, slugify 31.6x, dedupe_consecutive 2.2x, flatten_dict 1.3x, invert_dict 1.4x, clip_outliers 14.2x, rolling_window 2.4x, filter_range 2.5x (vs Python puro) / 7.1x (vs .filter(lambda)), partition_gt 3.1x (vs Python puro) / 6.2x (vs partition(lambda)), pipeline unique_by→groupby→sum 1.7x, pipeline nuevo filter_range→clip→rolling 3.4x. partition: 0.51x (más lento que Python puro; con Vectorcall desde 0.3.0, antes 0.60x — ver la explicación abajo). pad: 0.64x contra una función Python equivalente con la misma validación, 0.26x contra str.center() desnudo sin validación — ver la explicación abajo.
Con 2,000,000 elementos: fast_sum 1.9x, count_freq 1.5x, unique 1.6x, filter_gt 1.6x, groupby 1.8x, flatten 8.8x, unique_by 1.2x, chunk 1.1x, safe_get 1.1x, clamp 2.0x, pick 1.7x, omit 5.9x, deep_merge 13.8x, slugify 31.5x, dedupe_consecutive 1.9x, flatten_dict 1.3x, invert_dict 1.4x, clip_outliers 13.2x, rolling_window 2.0x, filter_range 2.2x / 5.7x, partition_gt 2.3x / 4.1x, pipeline unique_by→groupby→sum 1.5x, pipeline nuevo 3.1x. partition: 0.51x (antes 0.59x en 0.3.0). pad: 0.63x / 0.24x (sin cambios; ver la sección 0.3.0 sobre el experimento revertido).
Las dos especializadas de 0.3.0: filter_range y partition_gt
Estas dos son la respuesta directa a la limitación de partition/.filter() documentada abajo: cuando el "predicado" es en realidad una comparación numérica simple (un rango o un umbral), evitar el callback de Python cambia por completo el resultado. partition_gt pasa de la zona de "más lento que Python puro" (0.53x-0.60x, el número de partition con lambda) a 6.2x-4.1x más rápido que ese mismo partition(lambda), y 3.1x-2.3x más rápido que Python puro. filter_range muestra el mismo patrón: 7.1x-5.7x más rápido que .filter(lambda), y 2.5x-2.2x más rápido que Python puro. La causa es exactamente la que se sospechaba: sin el cruce Python→C→Python por elemento, el loop en C vuelve a tener la ventaja que se esperaría de una extensión nativa. El pipeline filter_range→clip→rolling (tres pasos, ninguno con callback) rinde 3.4x-3.1x frente a su equivalente en Python puro, frente al 0.64x-0.57x que daba el pipeline filter→map→dedupe_consecutive de la versión anterior (que sí tiene dos pasos con callback).
clip_outliers y flatten_dict/invert_dict
clip_outliers tiene una de las mejores ganancias (14.2x-13.2x) por la misma razón que deep_merge: hace trabajo genuinamente pesado en C sin ningún callback, aplicando la comparación y el PyFloat_AsDouble/PyFloat_FromDouble directamente en el loop, evitando el overhead de bytecode de una list comprehension con max(min(...)) por elemento. flatten_dict e invert_dict tienen ganancias más modestas (1.3x-1.4x) porque su costo ya está dominado por operaciones de dict que CPython ya implementa eficientemente en C por debajo (PyDict_Next, PyDict_SetItem); el beneficio ahí es sobre todo evitar escribir y mantener la recursión o el comprehension a mano, no la velocidad bruta.
partition sigue siendo la excepción negativa real (sin cambios respecto a 0.2.0)
partition es consistentemente ~40-41% más lento que el equivalente en Python (0.59x-0.60x), sin cambios respecto a la versión anterior — no se tocó su implementación en esta ronda porque el problema nunca fue la implementación en sí. La razón es estructural: partition invoca el predicado de Python una vez por elemento vía PyObject_CallFunctionObjArgs, y ese cruce Python→C→Python en cada iteración cuesta más de lo que se ahorra teniendo el loop externo en C. Lo mismo aplica a .filter() y .map() de FastList. La solución que aporta esta versión no es optimizar partition en sí (no se puede, sin cambiar qué acepta como argumento) sino ofrecer partition_gt/filter_range como alternativas sin callback para el caso — muy común en la práctica — donde el predicado es una comparación numérica simple. Si el caso de uso necesita un predicado arbitrario de Python, partition/.filter()/.map() siguen siendo la única opción, y su ganancia real ahí es de expresividad y de mantener todo en una cadena legible, no de velocidad.
pad: dos comparaciones honestas, no una
pad es un caso nuevo con una particularidad: el número "correcto" depende de contra qué se lo compare, y por eso el README reporta dos.
Contra str.center()/str.ljust() desnudos, sin ninguna validación ni soporte de mode unificado, pad es 0.24x-0.26x — notablemente más lento. Esto se investigó a fondo, no es un descuido: se probaron tres optimizaciones sucesivas (un único buffer de salida en vez de tres objetos intermedios, PyUnicode_Fill — la misma rutina que usa CPython internamente para estos métodos — en vez de escribir carácter por carácter, y una ruta de parseo de argumentos sin el mecanismo de keywords cuando no se pasa ninguno) y cada una redujo algo el costo pero ninguna cerró la brecha por completo. Lo que queda es el costo fijo de cruzar a través de la C API de extensión en cada llamada (empaquetar args, resolver el método, incrementar/decrementar referencias en el camino de llamada), que los métodos nativos de str evitan por estar integrados directamente en el tipo built-in con un camino de despacho más corto. Esto no tiene solución sin cambiar qué es pad — convertirlo en un método de str no es algo que una extensión externa pueda hacer.
Contra una función de Python que replique la misma funcionalidad de pad (validar que fill sea un solo carácter, unificar ljust/rjust/center bajo un parámetro mode, dar mensajes de error explícitos), pad rinde prácticamente igual: 0.63x-0.64x. Ese es el punto de comparación honesto, porque nadie que necesite esa validación va a usar str.center() a secas — va a escribir (o ya tiene escrita) una función wrapper con ese mismo costo de dispatch. La razón real de ser de pad nunca fue ganar velocidad sobre el built-in desnudo: es no tener que escribir y mantener esa función de validación uno mismo.
Funciones sin cambios de 0.1.1/0.2.0
flatten sigue teniendo una de las mayores ganancias (11.7x-8.8x) porque en Python puro depende de recursión con overhead de llamadas a función, que en C es casi gratis. deep_merge (14.0x-13.8x) y slugify (31.6x-31.5x) mantienen sus ganancias porque hacen trabajo pesado en C sin callbacks. safe_get se mantiene en ~1.1x más rápido que Python puro (mejora que ya se documentó en la versión 0.2.0, sin cambios en esta ronda). Las funciones que ya dependen de dict/set de Python (count_freq, unique, unique_by, fast_sum) ganan menos, porque esas estructuras ya están optimizadas en C por debajo del intérprete.
Cambios de la versión 0.3.0: auditoría de velocidad y simplificación de API
Esta versión no agrega funciones nuevas. Es una auditoría completa de las 23 funciones del módulo y los 14 métodos de FastList, revisando cada una en busca de (a) búsquedas o allocaciones redundantes que pudieran eliminarse sin cambiar el comportamiento observable, y (b) inconsistencias en nombres de parámetros o soporte de keywords entre funciones que resuelven problemas similares. El resultado se documenta con la misma honestidad que el resto del README: algunas mejoras son reales y medibles, otras son cambios estructuralmente correctos que no se traducen en una diferencia perceptible, y se reportan como tales en vez de inflar el número.
Optimizaciones de velocidad
groupby (función suelta) y FastList.groupby son las dos mejoras con impacto medible de esta ronda. La versión anterior de groupby hacía PyDict_GetItemWithError para comprobar si el grupo ya existía, y solo en el caso de grupo nuevo agregaba un PyDict_SetItem adicional — es decir, para cada elemento de un grupo que ya existe, había una búsqueda "de más" en el sentido de que PyDict_SetDefault puede resolver "buscar o crear con un valor por defecto" en una sola operación. FastList.groupby() tenía una capa adicional de indirección: mantenía un index_dict separado (clave → índice numérico en la lista de resultado) solo para poder envolver la salida en pares [clave, sublista] en vez de un dict plano. Como los dicts de Python (3.7+) ya garantizan orden de inserción, esa capa de índices era innecesaria: la nueva versión usa directamente un dict clave → [clave, sublista] y extrae dict.values() al final, preservando exactamente el mismo orden de aparición que antes (verificado con un caso de categorías intercaladas de forma no trivial). Medido sobre 200,000 elementos con 5 categorías: groupby mejora 1.02x-1.10x, FastList.groupby() mejora 1.01x-1.04x, y el pipeline completo groupby().sum() apenas 1.01x (la mejora se diluye porque .sum() vuelve a recorrer todos los grupos con su propio costo, que domina el tiempo total).
count_freq recibió el mismo tratamiento con PyDict_SetDefault (antes: PyDict_GetItemWithError + posible PyDict_SetItem, dos búsquedas para una clave repetida). flatten_dict se ajustó para evitar la llamada a PyObject_Str() cuando la clave de un dict ya es un str (el caso inmensamente más común), comprobándolo primero con PyUnicode_Check. Ambos cambios son correctos y más explícitos sobre la intención del código, pero medidos repetidamente no mostraron una mejora consistente por encima del ruido de medición (~0.97x-1.03x, oscilando de corrida a corrida) — el costo real en ambos casos está dominado por el hashing de los propios objetos, no por el número de operaciones de búsqueda en el dict. Se documentan aquí en vez de callarlos porque siguen siendo una limpieza estructural válida (menos trabajo redundante en el peor caso), solo que no es una ganancia que se pueda anunciar con un número honesto.
pad recibió tres intentos de optimización adicionales en esta ronda, más allá de los ya aplicados en 0.3.0: un único buffer de salida (ya estaba), PyUnicode_Fill en vez de escribir carácter por carácter (la misma rutina que usa CPython internamente para ljust/rjust/center), y una ruta de parseo de argumentos que evita PyArg_ParseTupleAndKeywords cuando no se pasa ningún keyword. Cada uno redujo algo el costo medido de forma aislada, pero el número final frente a str.center() desnudo no cambió de forma significativa (sigue en la misma zona ya documentada en 0.3.0). Se investigó a fondo — incluyendo aislar cuánto cuesta específicamente pasar un argumento como keyword (hasta ~1.4x más lento que la misma llamada sin keywords, medido de forma aislada) — y la conclusión sigue siendo la misma que en 0.3.0: el costo restante es el overhead fijo de cruzar la C API de extensión en cada llamada, que no tiene solución sin convertir pad en un método nativo de str, algo que una extensión externa no puede hacer. No se revirtió ningún cambio de pad porque, aunque no mejoraron el número frente al built-in desnudo, tampoco lo empeoraron, y el código quedó más claro sobre qué hace cada paso.
Se auditaron sin cambios (ya estaban en su forma óptima, o el margen de mejora no justificaba el riesgo): fast_sum, unique/unique_by (el patrón PySet_Contains + PySet_Add es el estándar de C para esto; no existe una alternativa pública más barata sin acceder a símbolos internos no garantizados entre versiones de Python), filter_gt, chunk, safe_get (ya optimizada en 0.2.0), clamp, pick, omit, deep_merge (se evaluó evitar la copia completa de sub-dicts en cada nivel de fusión, pero el caso de uso real —configs de tamaño moderado— no muestra un costo perceptible, y la complejidad de un "copy-on-write" parcial no se justificaba), slugify, partition/.filter()/.map() (se midió que el callback de Python representa ~72% del tiempo total; el 28% restante en overhead de crecimiento de listas no compensa el riesgo de una estrategia de pre-alocación que además desperdiciaría memoria en el caso típico), dedupe_consecutive, invert_dict (no existe una función pública de la C API para pre-dimensionar un dict antes de llenarlo, a diferencia de las listas), rolling_window, filter_range y partition_gt (ya en su forma óptima desde 0.3.0).
Simplificación de API
safe_get y flatten_dict renombraron su primer parámetro de dict_obj (un nombre idiomático de la C API interna, no de Python) a d, más corto y consistente con la convención usada en el resto de la documentación y los ejemplos del README. filter_range renombró input_list a lst por la misma razón. Estos cambios solo afectan a quien llamaba estas funciones con el nombre de keyword explícito (algo no documentado ni usado en ningún ejemplo previo del README); la forma posicional, que es la única documentada, sigue funcionando exactamente igual.
clip_outliers ganó soporte de keywords (lst, min_val, max_val), que antes no tenía pese a ser conceptualmente la función hermana más cercana de filter_range (ambas trabajan sobre un rango [min, max] de una lista de números) — antes solo se le podía pasar posicional, mientras que filter_range sí aceptaba nombres. Ahora fx.clip_outliers(lista, min_val=0, max_val=10) funciona igual que fx.filter_range(lista, min_val=0, max_val=10). Se verificó que agregar el mecanismo de keywords no penalizó el caso de llamada posicional (que sigue siendo la forma más común): medido repetidamente, el ratio quedó en la misma zona de ruido que antes del cambio, sin regresión.
Se consideró y se descartó agregar keywords a rolling_window y partition_gt: ambas reciben solo dos argumentos (lista + un número), donde el orden es obvio y el valor de nombrarlos es mínimo — agregar esa superficie de API habría sido complejidad sin beneficio real, lo opuesto al objetivo de esta ronda. También se descartó fusionar isinstance(value, list) + isinstance(value, tuple) en ensure_list en una sola comprobación isinstance(value, (list, tuple)): parecía una simplificación razonable, pero medido directamente resultó ser ~10-20% más lento que las dos comprobaciones separadas (CPython tiene un atajo más corto para el chequeo de un único tipo que para una tupla de tipos candidatos), así que se revirtió — se prefirió el código "menos elegante" porque es el que de verdad es más rápido.
Cambios de la versión 0.3.0: intento serio de cerrar la brecha en partition/.filter()/.map() y pad
Esta versión ataca directamente las dos únicas zonas documentadas como "igual o más lentas que Python": partition/.filter()/.map() (0.53x-0.60x) y pad (0.24x-0.64x según la comparación). El resultado es parcial y se documenta con la misma honestidad de siempre: una mejora real mantenida, un experimento que se probó y se revirtió, y un límite que sigue sin solución posible sin cambiar la API pública.
partition, .filter() y .map(): de CallFunctionObjArgs a Vectorcall
Se reemplazó PyObject_CallFunctionObjArgs por PyObject_Vectorcall (API pública y estable desde Python 3.9, el mínimo de este proyecto) en las tres funciones que invocan un callable de Python por elemento. Vectorcall pasa los argumentos como un array de punteros C en vez de empaquetarlos en una tupla de Python, evitando esa construcción intermedia en cada llamada. Medido de forma aislada con una función mínima en C, esto da ~5% de mejora consistente. Medido en el contexto real de partition() con una lambda como predicado, sobre listas de 200,000 y 2,000,000 elementos: el ratio pasó de 0.53x-0.60x a 0.51x-0.52x — es decir, dentro del margen de ruido, sin cambiar la conclusión de fondo. .filter() y .map() de FastList no mostraron ninguna mejora medible en absoluto (~0.99x-1.00x comparado contra la versión anterior). El cambio se mantiene de todas formas porque es correcto, no tiene riesgo, y no empeora nada — pero no se anuncia como la solución al problema, porque no lo es.
El techo real, confirmado con un experimento dirigido: se aisló específicamente cuánto cuesta invocar una lambda de Python (que crea un frame de ejecución interpretado) frente a invocar un builtin de C puro con el mismo trabajo — la lambda resultó ~39% más lenta solo por ese motivo, sin que ninguna API de invocación del lado de C pueda evitarlo. Este es el verdadero cuello de botella: no es cómo se invoca el callable desde la extensión, es que el callable en sí mismo es código Python interpretado. Ninguna optimización posible desde _fastcorex.c puede acelerar la ejecución del código Python que el usuario proporciona.
Se descartó explícitamente una vía más agresiva que sí se evaluó en profundidad: inspeccionar el bytecode de la lambda del usuario para detectar patrones de comparación simple (x > N) y resolverlos directamente en C sin invocar el protocolo de llamada. Es técnicamente posible — el bytecode de una lambda como lambda x: x > 5 es corto y reconocible — pero se rechazó por tres razones: (1) el bytecode exacto de CPython cambia entre versiones menores de Python, lo que obligaría a mantener una tabla de compatibilidad por versión; (2) la cobertura sería limitada (no cubre closures, funciones def, ni operator.gt); y (3) el riesgo más serio: un bug sutil en la detección de patrones podría producir resultados silenciosamente incorrectos sin ningún error visible, un tipo de fallo mucho peor que "sigue siendo lento". El costo de mantenimiento y el riesgo de correctitud superan la ganancia, así que no se implementó.
Hallazgo útil que sí se documenta como consejo práctico: cuando el predicado o la función ya es un builtin de C (bool, int, str.lower, un método de una clase de C, etc.) en vez de una lambda de Python, ese callable no necesita crear un frame de ejecución, y el costo cae dramáticamente sin ningún cambio de código de por medio — medido: partition(lista, bool) es 3.56x más rápido que partition(lista, lambda n: bool(n)) para el mismo resultado. Esto no resuelve el caso general (una lambda con lógica arbitraria sigue pagando su propio costo), pero es una alternativa real para quien pueda expresar su condición con un builtin.
pad: se probó METH_FASTCALL, se midió, y se revirtió
Se reescribió pad por completo usando METH_FASTCALL | METH_KEYWORDS en vez de METH_VARARGS | METH_KEYWORDS, extrayendo los argumentos manualmente desde un array de punteros C en vez de dejar que PyArg_ParseTupleAndKeywords construya y parsee una tupla. Un micro-benchmark aislado (una función mínima que solo recibe y devuelve dos argumentos) mostró 2.18x más rápido con este mecanismo, una promesa considerable. Se implementó la reescritura completa: parseo manual de hasta 4 argumentos posicionales o con nombre en cualquier orden, detección de argumentos duplicados (posicional + keyword para el mismo parámetro), keywords desconocidos, y todos los mensajes de error que ya tenía la versión anterior — validado con una batería de 12 casos de error distintos, todos correctos, y sin leaks de memoria en ningún camino (incluido el de retorno temprano cuando el texto ya mide más que el ancho pedido).
Medido en el contexto real de pad() completa (no la función mínima aislada) contra la versión anterior de 0.3.0: el resultado fue 1.01x en el caso posicional y 0.97x-1.01x con el keyword mode= — es decir, ninguna mejora neta perceptible. La razón: el ahorro de no construir la tupla de argumentos es una fracción minúscula del tiempo total de pad(), que está dominado por el resto del trabajo de la función (validar fill, resolver mode, calcular el relleno, escribir el buffer de salida) — el mismo patrón, en sentido inverso, que ya se había confirmado con count_freq/flatten_dict en la ronda 0.3.0 (una optimización teóricamente sólida que no se traduce en una ganancia medible porque el costo real está en otro lado).
Dado que la reescritura no aportó ninguna mejora real y sí agregó considerablemente más código (parseo manual de argumentos en vez de una lista kwlist declarativa, más superficie para bugs de mantenimiento futuro), se revirtió por completo: pad en 0.3.0 tiene exactamente la misma implementación que en 0.3.0 (PyArg_ParseTupleAndKeywords con la ruta rápida sin keywords ya aplicada en la ronda anterior). Los números de benchmark de pad en la sección anterior siguen siendo los vigentes; no cambiaron en esta ronda.
Conclusión honesta de esta ronda: se intentó en serio cerrar la brecha en ambos casos, con dos técnicas de la C API que en teoría debían dar mejoras sustanciales (Vectorcall, METH_FASTCALL), midiendo cada paso en vez de asumir que la teoría se traduciría en práctica. En partition/.filter()/.map() la mejora real quedó dentro del margen de ruido. En pad la mejora resultó ser cero, y el cambio se revirtió correctamente en vez de mantenerlo por inercia. Ambas funciones siguen exactamente en la misma categoría que antes de esta ronda: partition/.filter()/.map() con una lambda arbitraria seguirán siendo más lentas que Python puro porque el costo real es el propio código Python del usuario, no la extensión; y pad seguirá rindiendo por debajo de str.center()/ljust() desnudos por el costo fijo, inevitable desde una extensión externa, de cruzar la C API en cada llamada.
Licencia
MIT License. Ver el archivo LICENSE para el texto completo.
Estado del proyecto
Versión 0.3.. Sin funciones nuevas respecto a 0.3.0. Cubre patrones comunes de listas, diccionarios y strings (agrupar, deduplicar, aplanar y su inverso, contar, filtrar, particionar, fusionar, generar slugs, acceso anidado, acotar rangos, rellenar texto, ventanas deslizantes) más un tipo FastList que permite encadenar catorce operaciones distintas (groupby, sum, count, filter, map, unique_by, flatten, chunk, partition, dedupe_consecutive, clip, rolling, filter_range, partition_gt) sin pasar por dicts intermedios. No pretende reemplazar NumPy para cómputo numérico ni Pandas para análisis de datos tabulares.
Cubierto por pruebas automatizadas (tests/, ejecutables con python -m unittest discover -s tests o con pytest tests/): 275 pruebas en total — funciones originales de 0.1.1, 0.2.0, 0.3.0 y 0.3.0 (regresión), y una suite dedicada a la ronda 0.3.0
0 que verifica que el cambio a Vectorcall funciona correctamente con todo tipo de callable (lambda, función def, builtin, método de instancia, callable con estado) sin alterar la propagación de excepciones, y que pad() sigue comportándose exactamente igual tras revertir el experimento con METH_FASTCALL.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastcorex-0.3.0.tar.gz.
File metadata
- Download URL: fastcorex-0.3.0.tar.gz
- Upload date:
- Size: 59.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: python-requests/2.34.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2d5570f16d3b61b3f4563da7ce8266b07f1c74a9bc4ecbb463a0cc14ae8e8a28
|
|
| MD5 |
efca4efade59381efc9c7c45c1027ac6
|
|
| BLAKE2b-256 |
d815ff6ddc8a7fca35457e9396eda557addba84db2c41353e26fe38591768be3
|
File details
Details for the file fastcorex-0.3.0-cp314-cp314-android_24_arm64_v8a.whl.
File metadata
- Download URL: fastcorex-0.3.0-cp314-cp314-android_24_arm64_v8a.whl
- Upload date:
- Size: 60.1 kB
- Tags: Android API level 24+ ARM64 v8a, CPython 3.14
- Uploaded using Trusted Publishing? No
- Uploaded via: python-requests/2.34.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a094d7f114a32a3375d0cb4bee322e30535480dff4c190c958eee3424f08c57b
|
|
| MD5 |
b14d64523ec60951cb78eba8fd6ea1d5
|
|
| BLAKE2b-256 |
3405f897c8b368768e5a45b7b4e6be1beea93f8812d991c1c9a7cfc488bd0c04
|