Though using library like Scrutor or SimpleInjector is better option, due to constraints I had to hand-roll auto registeration with reflection. I've tried to filter types by their namespace like @Beej's answer, but I have since learned that it is more effective to explicitly mark classes with attributes after editing Startup.cs 3 times in a row in order to tweak lifetime.
Here is example to collect classes marked with attributes:
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class TransientAttribute : Attribute {}
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class ScopedAttribute : Attribute {}
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public class SingletonAttribute : Attribute {}
/*
Mark your classes with [Transient], [Scoped] or [Singleton] above then register like:
services.AddServicesByAttributes(typeof(Startup).Assembly);
*/
public static class AddByAttributeServiceCollectionExtensions
{
    public static IServiceCollection AddServicesByAttributes(this IServiceCollection services, Assembly assembly)
    {
        foreach (ServiceDescriptor d in assembly.GetTypes().SelectMany(MakeDescriptors).Distinct())
        {
            services.Add(d);
        }
        return services;
    }
    private static ServiceLifetime? FindLifetime(Type type)
    {
        return (
            type.IsDefined(typeof(TransientAttribute), false),
            type.IsDefined(typeof(ScopedAttribute), false),
            type.IsDefined(typeof(SingletonAttribute), false)
        ) switch
        {
            (false, false, false) => null,
            (true, false, false) => ServiceLifetime.Transient,
            (false, true, false) => ServiceLifetime.Scoped,
            (false, false, true) => ServiceLifetime.Singleton,
            _ => throw new ArgumentException($"Lifetime attribute specified more than once for {type}", nameof(type)),
        };
    }
    private static IEnumerable<ServiceDescriptor> MakeDescriptors(Type type)
    {
        if (FindLifetime(type) is not ServiceLifetime lifetime) { yield break; }
        if (type.IsGenericType)
        {
            // Handle Generic interfaces. Nested types not supported
            Type impl = type.GetGenericTypeDefinition();
            foreach (Type ifType in type.GetInterfaces())
            {
                // Strip type arguments
                Type service = ifType.IsGenericType ? ifType.GetGenericTypeDefinition() : ifType;
                yield return ServiceDescriptor.Describe(service, impl, lifetime);
            }
        }
        else
        {
            Type impl = type;
            yield return ServiceDescriptor.Describe(impl, impl, lifetime);
            // Alias for interfaces
            // NB. Dispose() may be called more than once for shared instances
            Func<IServiceProvider, object> factory = (sp) => sp.GetRequiredService(impl);
            foreach (Type service in type.GetInterfaces())
            {
                yield return ServiceDescriptor.Describe(service, factory, lifetime);
            }
        }
    }
}
I recommend this book by @ploeh
and @Steven that shows how to cope with ASP.NET Dependency injection.