# How do I make sure my cosine-weighted sampling works fine?

I'm working on a pathtracer and while it's working mostly fine, I'm getting different outputs with cosine-weighted sampling and uniform sampling :

Uniform sampling is on the left, cosine-weighted is on the right. The difference is somewhat subtle, but it does not go away with more samples so it's not some kind of start up bias.

I've checked my code against PBRT and Wikipedia but I cannot figure out what is wrong, because all the factors seem to be there (brdf, pdf, Li, and cos(theta)). I'm hoping someone here could take a fresh look at it :

// Sampling functions :
Vec3 sample_uniform_hemisphere(Sampler* sampler, float* pdf) {
float cosTheta, v;
sample_unit_square(sampler, &cosTheta, &v, NULL);
float sinTheta = sqrtf(1 - cosTheta * cosTheta);
float cosPhi = cosf(2 * M_PI * v);
float sinPhi = sinf(2 * M_PI * v);

if (pdf)
*pdf = .5f / M_PI;
return (Vec3) {
sinTheta * cosPhi,
sinTheta * sinPhi,
cosTheta
};
}

Vec3 sample_cosine_weighted_hemisphere(Sampler* sampler, float* pdf) {
Vec3 sample;
sample_unit_disc(sampler, &sample.x, &sample.y, NULL);

float z2 = 1.0f - sample.x * sample.x - sample.y * sample.y;
sample.z = sqrtf(z2 > 0.0f ? z2 : 0.0f);
if (pdf)
*pdf = sample.z / M_PI; // p(theta, phi) = cos(theta) / pi
return sample;
}

// Snippet that does the actual shading :
float pdf;
Vec3 bounce_sample = it.material->bsdf->sampler(it.sampler, local_out, &pdf);
Ray bounce = {
.o = surface_point,
.d = basis2world(bounce_sample, u, v, it.normal)
};

Intersect bounce_it = trace_ray(sc, bounce, it.depth + 1, it.sampler);
Color f = it.material->bsdf->f(it.material->params,
bounce_sample, local_out);
Color dc = cmul(